AI Engineering from Scratch · Manual© 2026 Rohit Ghumare · MIT license

Agent Substrate 101

Install, run, connect, and debug stateful agent actors on Kubernetes, with a measured answer for each decision

Agent Substrate 1.0.0 · b34fbee · 2026-11-05 · Edition 2026.11
  • Substrate on One Page
  • Start
  • Actors
  • Workers and Capacity
  • Identity and Access
  • Network
  • Integrations
  • Operate and Debug
  • Build
Plate IOne request wakes one actor
One request wakes one actor An isometric drawing of one request. A client terminal sends POST / through kubectl ate proxy. In the namespace ate-system, atenet-router with the Envoy mark calls ResumeActor on ate-api-server, which reads the actor row in Postgres and binds a worker. Below is the node kind-control-plane with the Kubernetes mark. On it are a worker pod drawn as an open frame, a gVisor sandbox inside it, atelet, and the object store as a bucket. atelet restores the golden snapshot into the sandbox, and the actor my-counter-1 goes from SUSPENDED, a dashed outline, to RUNNING, three solid layers for memory, file, and rootfs. The router then opens a tunnel to port 8443. The first request took 0.358 s, the next four took 0.062 s to 0.091 s, and a request after 900 s with no traffic took 0.080 s. THE REQUEST PATH · AGENT SUBSTRATE V1.0.0 my-counter-1 client curl -X POST kubectl ate proxy atenet-router Envoy dataplane ate-api-server control plane Postgres namespace ate-system actor row worker pod gVisor sandbox my-counter-1 memory file rootfs RUNNING SUSPENDED atelet object store rustfs bucket node kind-control-plane MEASURED AT THE CLIENT through kubectl ate proxy, x86 server guest after 900 s with no request: 60 of 60 samples RUNNING then a request took 0.080 s POST / 1 ResumeActor 2 read, bind 3 4 Restore 5 golden snapshot 6 tunnel, port 8443 7 first request 0.358 s next four 0.062 s to 0.091 s
On our test server, the first request to a suspended actor took 0.36 s, and the next four took 0.06 to 0.09 s. The router asked ate-api-server before each one.

How to read this manual

Install Agent Substrate on a kind cluster from the release images, wake one actor with a request, and then learn each part with the number we measured for it.

This manual is for the engineer who runs agents on Kubernetes and must decide if Agent Substrate fits. It starts with a cluster that answers a request. It ends with a table that takes you from an error text to its cause.

These commands create a kind cluster, install Agent Substrate v1.0.0 from the release images, and create one actor:

bash
git clone --branch v1.0.0 https://github.com/agent-substrate/substrate.git
cd substrate
hack/create-kind-cluster.sh
export ATE_INSTALL_KIND=true
go run ./cmd/ate-setup deploy ate-system \
  --credential-provider='{"name":"k8s.io"}' \
  --ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client \
  --image-repo ghcr.io/agent-substrate/substrate --image-tag v1.0.0
go run ./cmd/ate-setup deploy demo counter
go install ./cmd/kubectl-ate
export PATH="$(go env GOPATH)/bin:$PATH"
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter
kubectl ate proxy actor my-counter-1 -a ate-demo-counter

The last command stays in the foreground and listens on port 8000. In a second terminal, send the actor a request:

bash
curl -X POST http://localhost:8000/

The actor answers with a count, and each new request adds one. Section 2.1 runs the install step by step, with the output to expect and the fix for each error we hit. Section 2.3 explains the actor and the proxy.

You need Go 1.27, Docker, git, and kubectl. The release has no ate-setup binary, so the installer runs with go run from the checkout. The release does have kubectl-ate binaries, and section 2.2 installs one without Go.

We measured this install once on a second x86 server guest (16 vCPU, 23 GB). The kind cluster took 55 s, deploy ate-system took 162 s, and deploy demo counter took 94 s. The whole path took about five minutes. Raw runs

We measured the same install once on an Apple silicon laptop under Docker Desktop, with 10 CPU and 8 GB for Docker. The kind cluster took 119 s, and deploy ate-system took 702 s. Suspend and resume worked there with the gVisor sandbox class. The laptop and the server guest are different machines, so do not compare their seconds. Raw runs

Do not build from source for a first install. The command without --image-repo and --image-tag builds every image, and it needs the Docker buildx plugin and a Rust compile. On the x86 server guest (24 vCPU, 47 GB, nested KVM) it failed once and then took 266 s. That guest is another machine than the one above, so the difference in seconds is indicative only.

What you can do after this manual

  • Install Agent Substrate on a kind cluster from the release images, create an actor, and send it a request.
  • Predict how long an actor takes to resume from the memory that holds data in it, and choose between an asynchronous and a synchronous suspend.
  • Size a WorkerPool, and say what a client sees when the pool is full.
  • Write an egress policy, inject a credential, and choose between the Envoy and the agentgateway dataplane.
  • Find the cause of a 500 or a 503 from its text, and bring a crashed actor back with RevertActor.

Who this manual is for

You run agents on Kubernetes, or you plan to. You know pods, Services, and kubectl. You have not used Agent Substrate, and you want to know what it does and what it costs before you depend on it.

The Agent Substrate documentation describes each object and each flag. This manual tells you what happened when we ran them. We installed v1.0.0 on our own clusters, broke it on purpose, and timed it. Where our run disagrees with a page of the documentation, we say so and we show the recording.

We hold eight positions, and each one comes from a run. Section 1.1 lists them with the number behind each one. The first position matters most: Substrate does not suspend an idle actor, so something that you run must do it.

What we did not test

All our clusters but one had one kind node, and the sandbox class was gVisor in every run behind Parts 1 and 2. We did not run Agent Substrate on GKE. Our object stores outside the cluster ran on the same guest and on a second guest of the same server. We did not use a cloud bucket. Our scale runs used one node, with up to 300 workers and 300 resumes at one time.

The only real model in our runs was a small open model on CPU, behind kagent and behind Agent Executor. So no model time in this manual is the time of a GPU or of a hosted model.

Each section says so where one of those limits applies. How this manual was made describes the machines, the method, and the full list.

How the parts are ordered

Part 1 is Substrate on one page: what it does, and one request from the client to the actor. Part 2 installs it and creates your first actor. Part 3 is the actor: its template, its states, suspend, resume, and what a snapshot keeps.

Part 4 is capacity: worker pools, sandbox classes, and a full pool. Part 5 is identity, and Part 6 is the network in both directions. Part 7 connects kagent and agentgateway, and it says when Substrate is the wrong choice. Part 8 operates and debugs a cluster. The Reference holds the lookup tables:

PartWhat it covers
1 · Substrate on One PageWhat Substrate does and what it leaves to you, then one request from the client to a suspended actor and back.
2 · StartThe install with ate-setup, the kubectl ate plugin, a first actor behind the proxy, and the objects that the install created.
3 · ActorsThe ActorTemplate and its golden snapshot, the seven states, suspend and resume with their measured cost, snapshot fidelity, CRASHED and RevertActor, and atespaces.
4 · Workers and CapacityThe WorkerPool, the gVisor and microVM sandbox classes, placement, request parking, and how to size a pool.
5 · Identity and AccessHow a client authenticates, how an access policy limits an atespace, and what an actor JWT carries.
6 · NetworkThe router and ingress, the egress policy and default deny, the DNS relay, TLS interception, credential injection, and the choice between Envoy and agentgateway.
7 · Integrationskagent 1.0 on Substrate, agentgateway with Substrate, Substrate on GKE, when Substrate is the right choice, and Agent Executor.
8 · Operate and DebugPostgres and the object store, what fails when a dependency is down, symptoms and their fixes, the upgrade path, and the limits at v1.0.0.
9 · BuildAn idle suspender, an agent actor that keeps its conversation, a sandbox for commands you do not trust, and a drill for six failures.
R · ReferenceThe methods, objects, states, commands, flags, errors, terms, and sources this manual uses, in one place.

Each section ends with a short list of actions. Read those first when you need an answer today. Read the rest when you want the reason.

Four lessons go deeper into the ideas that this manual applies. The production runtimes lesson compares the ways to run an agent in production. The durable execution lesson and the checkpoints and rollback lesson build the ideas behind suspend, resume, and revert. The AI gateways lesson covers the gateway in front of a model. This manual stays with Agent Substrate.

Colour in figures

Each hue keeps one meaning in every figure:

bluethe Substrate control plane: ate-api-server, ate-controller, ate-idp-server
violetworkers and sandboxes: atelet, worker pods, gVisor, microVM
greenthe actor: the workload you run
plumthe agent harness and model calls: kagent, the model
olivethe client and hosts outside the cluster
indigothe dataplane: atenet-router, atenet-egress, Envoy, agentgateway
amberlifecycle: actor states and the steps between them
tealdurable state: Postgres, the object store, snapshots
rosefailure: a crashed actor, a 500, a 503, lost calls
greyoutside Substrate: Kubernetes, the node, the operator

Figure 0.1 teaches the arrow styles with exchanges from our recordings. A solid arrow is a call, and a dashed arrow in the same ink is its reply. The other six styles each mark one kind of event, and the colour of the arrow matches the table above.

Fig. 0.1reading a sequence figure in this manualsequence
Reading a sequence figure in this manual Six lifelines: the client, atenet-router, ate-api-server, Postgres, the worker pod that runs my-counter-1, and other hosts. Eight numbered rows use the eight arrow styles: POST with the ate-target-actor header as a solid ink call, the worker assignment row as a solid teal durable write, SUSPENDED to RUNNING as a dashed amber state change, the CONNECT tunnel on port 8443 as a dotted indigo stream, a POST to mock-llm as a solid plum model call, a GET to example.org as a dashed olive outside effect, 200 with count 1 in 0.36 s as a dashed ink reply, and 503 no worker has room as a dashed rose failure. client curl atenet-router dataplane ate-api-server control plane Postgres actor rows worker pod my-counter-1 other hosts not Substrate 1 POST /, ate-target-actor a call · solid ink 2 worker assignment row a durable write · solid teal 3 SUSPENDED to RUNNING a state change · dashed amber 4 CONNECT tunnel, port 8443 a stream · dotted indigo 5 POST to mock-llm:8080 a model call · solid plum 6 GET https://example.org/, 200 an outside effect · dashed olive 7 200, count 1, 0.36 s a reply · dashed ink 8 503 no worker has room a failure · dashed rose
Each arrow style marks one kind of exchange, and every label is a real name, message, or number from our recorded runs. Read each numbered row from the tail of the arrow to its head. The note under each label names the style.
Part 1

Substrate on One Page

Substrate runs many actors on few worker pods, and it leaves the decision to suspend an actor to you.

  1. 1.1What Substrate does, and what it leaves to you
  2. 1.2One request, from the client to the actor and back

What Substrate does, and what it leaves to you

Substrate runs many stateful actors on few worker pods and resumes one when a request arrives, and it leaves the suspend call, the pool size, and GPU work to you.

You run one pod for each agent session. Most sessions wait for a person or for a model, and each waiting pod still holds its CPU and its memory. If you delete the pod, the session loses its memory. If you keep the pod, you pay for a process that does nothing.

When you finish this section, you can say what Agent Substrate does about that problem and name the jobs it leaves to you. You can also find the section that proves each of our eight positions.

The mechanism: actors, workers, and snapshots

Substrate separates the session from the pod that runs it.

Actor: one stateful workload with a name, such as my-counter-1. An actor has memory, files, and a root filesystem. It is a row in Postgres, and it is not a Kubernetes object.

Worker: one pod that can run actors inside a sandbox. A WorkerPool is the Kubernetes object that creates worker pods, and the pool has a fixed number of them.

Snapshot: the saved state of one actor. Substrate writes it when the actor is suspended and reads it when the actor is resumed.

Atespace: the named group that an actor, its template, and its policies belong to. An actor is always addressed by its atespace and its name.

A suspended actor uses no worker. It is a snapshot and a row. When a request arrives for it, Substrate chooses a worker with room, restores the snapshot there, and sends the request on. When something suspends the actor again, Substrate writes a new snapshot and frees the worker. Figure 1.1 shows the three worker pods of our demo pool with one actor that runs.

Fig. 1.1what Substrate does and what it leaves to youflow
What Substrate does and what it leaves to you Three worker pods of the WorkerPool counter sit in a row. The middle pod, counter-7cc7dcd596-ffxwt, runs the actor my-counter-1, and the other two run no actor. Below them the object store holds one snapshot for each suspended actor, and ate-api-server with Postgres holds the state of every actor. A dashed amber arrow from the object store to the actor says that a request resumes the actor, which Substrate does, in 0.36 s in our run. A thick teal arrow from the actor to the object store is SuspendActor, which you must call: after 900 s with no request the actor was RUNNING in 60 of 60 samples. Two lists at the bottom divide the work. Substrate resumes on a request, writes the snapshot at a suspend, routes each request, and limits what an actor can reach. You call SuspendActor, change the number of workers, call RevertActor after a crash, and run GPU work elsewhere. WORKERPOOL COUNTER · 3 WORKER PODS, EACH HAS ROOM FOR 1 ACTOR OF THIS SIZE counter-7cc7dcd596-5tj9h no actor counter-7cc7dcd596-ffxwt CPU 1000 of 1000 millicores counter-7cc7dcd596-p6g2q no actor my-counter-1 ACTOR_STATE_RUNNING object store one snapshot for each suspended actor ate-api-server and Postgres the state of every actor and worker a request resumes it Substrate does this: 0.36 s SuspendActor you must call it chooses the worker SUBSTRATE DOES YOU DO resumes an actor on a request: 0.36 s writes the snapshot at a suspend: 0.40 s routes each request to the right worker limits what an actor can reach call SuspendActor: RUNNING after 900 s idle change spec.replicas: no autoscaler call RevertActor after a crash run GPU work elsewhere: no device support
Substrate resumes an actor onto a worker when a request arrives, and the call that suspends it again must come from you. The top row is the WorkerPool of the counter demo, and the dashed pods run no actor. The amber arrow is the resume, which Substrate does. The thick teal arrow is the suspend, which you call. The two lists at the bottom divide the work, with the seconds from our run.

So a pool of three workers can hold hundreds of sessions, as long as few of them run at one time. That is the whole offer. The cost is the time to resume, and section 3.3 measures it against the memory that holds data in an actor.

What Substrate standardizes

Substrate gives you five things with fixed names.

  • One API. ate-api-server serves the Control gRPC service for atespaces, templates, actors, snapshots, workers, and policies. The kubectl ate plugin is its client. A second service, WorkerService, is for atelet only.
  • One lifecycle. An actor has seven states, and five operations move it between them: create, resume, suspend, revert, and delete. Section 3.2 draws them.
  • One way to reach an actor. A client sends a request to atenet-router with the header ate-target-actor and a client certificate. The router finds the worker.
  • Two sandbox classes. An actor runs under gVisor or in a microVM, and the WorkerPool decides which one. We ran both on one machine, and section 4.2 compares them.
  • One egress rule. An actor reaches no host until an egress policy names that host. Section 6.2 shows the denial.

The project states that it keeps this API compatible from v1.0.0. That promise covers the API. It does not cover the jobs below.

What it leaves to you

The suspend call

Substrate does not suspend an idle actor. It has no idle timer. An actor that got one request stays in ACTOR_STATE_RUNNING and holds its worker until a caller sends SuspendActor.

We measured this on an x86 server guest with 24 vCPU and 47 GB, in one run. We sent five requests to my-counter-1 and then sent nothing for 900 s. We read the state every 15 s, and all 60 samples were ACTOR_STATE_RUNNING. Raw runs

The quickstart page of the project says that an idle actor is suspended for you. The API guide of the same project says that it is not. Our run agrees with the API guide. If you plan capacity from the quickstart sentence, your pool fills with idle actors.

The program that drives the actor has this job. kagent takes it: in our kagent run, 98 of 98 turns ended with a suspend. Section 7.1 shows that run. If you write your own harness, the suspend call is part of your harness.

The pool size

Substrate has no autoscaler. A WorkerPool has a spec.replicas field and a scale subresource, so a Kubernetes autoscaler can change it. Nothing in Substrate does. When every worker is full, a request for one more actor waits and then fails.

We measured that failure on the same guest, in eight requests to four actors of one run. Each request waited 5.06 s at the median (n=8) and then returned 503 with the text no worker has room for the actor. Section 4.4 explains the wait, and section 4.5 sizes a pool. Raw runs

GPU work

Substrate v1.0.0 does not give an actor a GPU. The API guide marks devices as not supported for now. We did not test GPUs. Keep model inference on a serving system, and let the actor call it through an egress policy.

Recovery

Substrate marks an actor as crashed, and then it waits for you. A crashed actor returns 503 on every request until a caller sends RevertActor. In one case it is not marked at all: a process that exits leaves the actor in ACTOR_STATE_RUNNING. Section 3.5 shows both cases.

We measured the exit case on the x86 server guest (24 vCPU, 47 GB, nested KVM), with two actors. Both read ACTOR_STATE_RUNNING for 20 minutes after the exit, until we called a suspend. One of them got 20 requests in that time, and all 20 returned 503. Each dead actor kept its CPU on the worker for all that time. Raw runs

Substrate also does not move an actor off a node that stops.

We measured this on a second x86 server guest (16 vCPU, 23 GB), on a cluster with two worker nodes, one run for each case. A suspended actor had its snapshot in the object store. It stayed in ACTOR_STATE_RESUMING on the stopped node for 5 min 33 s from its first request. It was crashed 5 min 44 s after the stop command. A running actor read ACTOR_STATE_RUNNING for the 10 minutes of our probe. The other node had free workers the whole time, and section 4.3 has the four cases. Raw runs

We measured 13 more cases on the same guest (16 vCPU, 23 GB), once each, to find what an operator can do. No actor left the stopped node by itself. One revert freed an actor in 7.1 s, and only for an actor that was suspended with --mode sync and had got no request. In the other cases a revert during the outage left the actor in ACTOR_STATE_REVERTING until the node was back. A new actor from a tag that we had made before the stop served a request 1.2 s after the create command. So you own the recovery from a node loss, and section 8.2 has the runbook. Raw runs

Substrate also has no opinion about the agent. It does not choose a model, run a tool, or keep a conversation. Those belong to the harness.

Our eight positions

Each position comes from a run on our own clusters. The table is the map of this manual.

We measured every number in this table on two x86 server guests, one run each unless the section says more. The section in the last column names the machine and the count. The guests are different machines, so do not set a number from one row against a number from another row. Raw runs

PositionThe number behind itSection
Substrate does not suspend an idle actor60 of 60 samples RUNNING in 900 s3.3, 7.1
A suspend is asynchronous, and its snapshot is not durable at oncePENDING on one node for 5 minutes3.4, 3.5
Postgres and ate-api-server are on every request path, and the Kubernetes API server is on none103 of 103 requests from a pod served while kube-apiserver was down for 86 s on Envoy, and 106 of 106 in 87 s on agentgateway1.2, 8.2
Resume time grows with the memory that holds data in an actor0.22 s at 0 MiB, 2.16 s at 1000 MiB of random bytes, 0.23 s at 500 MiB of zero bytes3.3
agentgateway is a dataplane option of Substrate, and it does authenticate ingress clients38 MB against 91 MB at idle6.6, 7.2
An egress policy allows hosts and ports, and a removed rule does not close an open connectiona second request passed on an open pass-through connection, 20 s after the rule was removed. A suspend closed an open connection in 1.3 to 2.2 s6.2 for the policy, 6.6 for the open connection
A crashed actor is not always marked crashedRUNNING for 20 minutes after the process exited, in 81 of 81 samples3.5
Install from the release images162 s on the second guest (16 vCPU, 23 GB). The source build took 266 s on the first guest (24 vCPU, 47 GB), after a failed step. Two machines, so the seconds are indicative only.2.1

Read the positions as a list of things to check on your own cluster. Your seconds will differ. The states, the error texts, and the order of events must not.

What the project site says, and what we measured

The project site, ate.dev, reached version 1.0 with the release. We read it on 2026-11-12. Its documentation pages are built from the same files as the release tag, and we found no difference in content. Its first page makes short statements, some with numbers and none with conditions. The table sets each statement beside our result.

We measured every result in this table on x86 server guests, one run each unless the section says more. The section in the last column names the machine and the count. Raw runs

The site saysOur resultSection
Idle agents suspendNot seen. An idle actor stayed RUNNING for 900 s. The architecture page says that the caller requests the suspend.3.3, 9.1
An actor resumes in under 500msTrue for one resume, and for 10 at one time, up to about 200 MiB of data, on gVisor, from the node. 2.16 s at 1,000 MiB. 0.96 s at 200 MiB from the object store. With 50 resumes at one time on one node, the median was 1.2 to 1.3 s, and with 300 it was 7.3 to 7.4 s.3.3, 4.2, 4.5
An actor resumes on any ready workerNot seen for a node that stops. 5 of 5 resumes stayed on the node that held the snapshot. No actor left a stopped node by itself, in 17 cases. One revert at the right moment freed a quiet actor in 7.1 s. After a drain, 3 of 3 suspended actors did resume on the other node.4.3, 8.2
The Kubernetes API is off the request pathConfirmed on both dataplanes. 103 of 103 requests from a pod were served while kube-apiserver was down for 86 s on Envoy, and 106 of 106 in 87 s on agentgateway.8.2
Kubernetes handles recoveryTrue for nodes and pods. An actor on a stopped node needed an operator: a tag, a revert at the right moment, or the node back. After an eviction, the returned node stayed empty until we moved pods by hand.8.2, 3.5
Memory and files survive a suspendConfirmed with the memory fidelity. With the other two fidelities, the process starts again.3.4
No inbound access without authenticationConfirmed for the two actor ports on both dataplanes. The stats port of the agentgateway router answered /metrics with no credentials.6.1
No outbound access without a policyConfirmed. A name gets NXDOMAIN, and an IP gets a reset or a 403.6.2
The actor does not hold the secretConfirmed for the placeholder swap. An upstream that returns its request headers gives the secret to the actor.6.5
500+/s suspend and resume activationsNot reproduced on one node. One node with 24 vCPU finished 32 to 39 resumes each second, with 50 to 300 at one time, in two runs. The control plane was not the limit, and our reading puts it in the gVisor restores on the node. We did not test more nodes, so this is no result for a larger cluster.4.5
10× the density of container runtimesNot tested as stated. A suspended actor used no worker and 12.9 MB of node disk, and an idle worker used 16.7 MB of memory. We compared with no container runtime.4.5
Millions of agentsNot testable on our machines. With 10,750 actor rows on one node, the database was 20 MB and a single resume took the same 0.36 s.4.5
Worker state is scrubbed between runsNot tested. The security page lists this guarantee as in progress.no section
JWTs federate with every cloud providerWe decoded one token. We did not test federation.5.3

Three statements match our runs as written. Four hold under a condition that the site does not state. Two did not match what we saw. One we did not reproduce on one node, and four we did not test or could not test at our size. The two that did not match are the ones to plan for. You must send the suspend, and you must act when a node stops.

When this is the wrong tool

Substrate fits sessions that keep state and are idle most of the time. It does not fit an agent that runs all day, because that actor never frees its worker. It does not fit a workload that needs a GPU in the sandbox. Section 7.4 compares the choices, and section 8.5 lists the limits at v1.0.0.

One request, from the client to the actor and back

A request to a suspended actor passes the proxy, atenet-router, ate-api-server, and Postgres before a worker restores the actor, and a request to a running actor still asks all four.

You sent curl -X POST http://localhost:8000/ and a count came back. Six programs handled that request, and one of them wrote to a database before the actor ran. When a request fails later, the error text names one of those programs, so you need to know the order.

When you finish this section, you can name each hop of one request and say which hops a running actor skips. You can also say which parts of the cluster must be up for a request to pass.

The recording

We created my-counter-1 in the atespace ate-demo-counter and started kubectl ate proxy. Then we sent five requests, one after the other. This is the start of the recording. The columns are the time, the event, the HTTP status, the seconds at the client, and the body.

outputfive requests to a new actorFull log
ts	event	detail
2026-11-12T10:14:21.286Z	state	ACTOR_STATE_SUSPENDED	-
2026-11-12T10:14:21.837Z	call-1	200	0.358379	preserved memory count: 1 | preserved file counter: 1 | preserved rootfs counter: 1
2026-11-12T10:14:21.944Z	call-2	200	0.090770	preserved memory count: 2 | preserved file counter: 2 | preserved rootfs counter: 2
2026-11-12T10:14:22.030Z	call-3	200	0.066903	preserved memory count: 3 | preserved file counter: 3 | preserved rootfs counter: 3
2026-11-12T10:14:22.109Z	call-4	200	0.061842	preserved memory count: 4 | preserved file counter: 4 | preserved rootfs counter: 4
2026-11-12T10:14:22.196Z	call-5	200	0.068053	preserved memory count: 5 | preserved file counter: 5 | preserved rootfs counter: 5
2026-11-12T10:14:22.203Z	state	ACTOR_STATE_RUNNING	-
The first eight lines of the trace. The actor state was read before the first request and after the fifth.

We measured these requests on an x86 server guest with 24 vCPU and 47 GB, through kubectl ate proxy, in one run. The first request took 0.358 s. The next four took 0.091 s, 0.067 s, 0.062 s, and 0.068 s. Raw runs

The actor was ACTOR_STATE_SUSPENDED before the first request and ACTOR_STATE_RUNNING after the fifth. No command resumed it. The first request did.

Hop by hop

Figure 1.2 draws the first request. The numbers in this list match the rows of the figure.

Fig. 1.2the first request to my-counter-1sequence
The first request to my-counter-1 Six lifelines, each under a small drawing of its party: curl as a terminal, the kubectl ate proxy actor as a small unit with the Kubernetes mark, atenet-router as a gateway with the Envoy mark, ate-api-server as a server with the Agent Substrate mark, Postgres as a cylinder with its mark, and the worker pod as a frame that holds a gVisor sandbox and the actor. Nine numbered exchanges: curl sends POST to 127.0.0.1:8000, the proxy opens an mTLS CONNECT to router port 8444 through a kube-apiserver port-forward, the router calls ResumeActor, ate-api-server reads the actor row and finds it suspended, takes the lease and binds a worker, has atelet restore the golden snapshot so the actor goes from SUSPENDED to RUNNING, and returns worker 10.244.0.23 to the router. The router opens a CONNECT to worker port 8443, and curl gets 200 with count 1 after 0.358 seconds. Steps 5 and 6 have amber numbers because they happen only when the actor is suspended. In a later resume of the same actor the restore took 0.25 seconds, and a request to the running actor took 0.067 seconds. curl client kubectl ate proxy actor atenet-router dataplane ate-api-server control plane Postgres actor rows worker pod my-counter-1 1 POST localhost:8000/ 2 mTLS CONNECT to port 8444 through a kube-apiserver port-forward 3 ResumeActor sent for every request 4 read the actor row ACTOR_STATE_SUSPENDED 5 take the lease, bind a worker 6 atelet restores the golden snapshot SUSPENDED to RUNNING, 0.25 s in a later resume 7 worker 10.244.0.23 with its SPIFFE ID 8 CONNECT to port 8443 then actor port 80 9 200, count: 1 0.358 s at the client 5 6 only when the actor is suspended. A running actor skips them and answers in 0.067 s.
The first request to a suspended actor makes the router call ResumeActor, and ate-api-server restores the actor before the request reaches it. Read the rows from top to bottom. The drawing above each lifeline is its party, with the mark of its project. Row 2 is the only one that passes kube-apiserver. Rows 5 and 6 have amber numbers because a request to a running actor skips them. The note under row 6 is the restore time of a later resume of the same actor.
  1. curl sends the request to 127.0.0.1:8000, where kubectl ate proxy listens.
  2. The proxy opens a port-forward through the Kubernetes API server to atenet-router. Inside that port-forward it opens an mTLS CONNECT tunnel to router port 8444, with a client certificate.
  3. The router reads the target actor and calls ResumeActor on ate-api-server. It does so for every request, because it keeps no record of where an actor runs. Requests that arrive at the same time for one actor share one call.
  4. ate-api-server reads the actor row from Postgres. The state is ACTOR_STATE_SUSPENDED.
  5. ate-api-server takes a lease on the actor, chooses a worker with room, and writes the assignment to Postgres in one transaction.
  6. ate-api-server tells atelet on that node to restore the snapshot. The worker starts the sandbox from it, and the actor state becomes ACTOR_STATE_RUNNING.
  7. ate-api-server returns the actor to the router, with the IP address of the worker pod and the identity of the worker.
  8. The router opens a tunnel to port 8443 of the worker pod and checks that identity. The worker forwards the request to port 80 of the actor.
  9. The reply goes back the same way.

A new actor has never run, so the snapshot in step 6 is not its own. It is the golden snapshot of its template, and section 3.1 explains it. After step 7 the actor object carries the assignment:

jsonthe actor after the first requestFull log
    "state": "ACTOR_STATE_RUNNING",
    "workerAssignment": {
      "nodeName": "kind-control-plane",
…
      "workerEpoch": "1",
      "workerNamespace": "ate-demo-counter",
      "workerPod": "counter-7cc7dcd596-ffxwt",
      "workerPodIps": [
        "10.244.0.23"
      ],
…
      "workerPool": "counter",
      "workerSpiffeId": "spiffe://cluster.local/ns/ate-demo-counter/sa/ateworker"
Cut from the actor object. The metadata, the snapshot list, and the sandbox version attributes are cut.

Where the 0.36 s goes

The first request paid for the restore. The four after it did not. The difference is about 0.29 s on this guest, for an actor that holds almost no memory.

The worker writes one log line when a restore starts and one when it ends. We did not save that log for the first request. We saved it for a later resume of the same actor:

outputone restore in the lifecycle logFull log
{"time":"2026-11-12T10:30:17.846333794Z","message":"Actor restoring",…
{"time":"2026-11-12T10:30:18.098955499Z","message":"Actor restored",…
The span and trace fields of each line are cut.

We measured that resume on the same guest, once. The two log lines are 0.253 s apart, and the request that caused the restore took 0.382 s at the client. So about two thirds of a first request is the restore itself. Raw runs

That share grows with the actor. A restore reads the memory that the actor held, and section 3.3 shows 2.16 s for an actor with 1000 MiB.

A request to a running actor

A request to a running actor still does steps 1 to 4. ate-api-server reads the row, finds ACTOR_STATE_RUNNING, and returns at once. Steps 5 and 6 do not happen. So a request costs one call to ate-api-server and one read from Postgres, also when nothing is resumed. Requests that arrive together for one actor share that call.

Most of the 0.06 s in our recording is the proxy, not Substrate.

We measured both paths on the second x86 guest, with 16 vCPU and 23 GB, in the 30 s before a test. A pod that sent its requests to the router answered in 0.027 s at the median, with 30 requests. The same actor through kubectl ate proxy answered in 0.067 s at the median, with 28 requests. Raw runs

Use the number from the pod when you plan latency. The proxy is a tool for your own machine, and section 2.3 shows why it must not carry real traffic.

What must be up

Three things follow from the path.

  • Postgres and ate-api-server are on every request path. A running actor that needs no resume still fails when either one is down. On our second guest, requests to a running actor failed within seconds of each outage. Section 8.2 has the timeline.
  • The Kubernetes API server is on no request path. Only row 2 of the figure passes it, and that row belongs to the proxy. In the same test, kube-apiserver was down for 86 s. In that time 103 of 103 requests from a pod were served: 86 to a running actor and 17 resumes.
  • The object store is on the path only when a snapshot is not on the node. Section 3.4 says when that is.

The install runs one atenet-router pod and one Postgres pod. Every request to every actor passes both. Section 8.1 says what to do about Postgres.

The actor stays up

After the fifth request we sent nothing for 900 s. Figure 1.2b shows all six requests of the recording, and every state sample between the last two.

Fig. 1.3six requests to my-counter-1, and its state for 900 idle secondschart
Six requests to my-counter-1, and its state in the 900 seconds between the last two The upper part has six horizontal bars with the client time of each request through kubectl ate proxy on the x86 server guest: request 1 0.358 s, request 2 0.091 s, request 3 0.067 s, request 4 0.062 s, request 5 0.068 s, request 6 0.080 s. The first bar is amber because it includes the resume from the golden snapshot, which adds about 0.29 s. The other bars are green because the actor was already running. The lower part is a time axis of the 913 seconds with no request between request 5 and request 6. It has one dot for each of the 60 state samples, and all 60 are in the row ACTOR_STATE_RUNNING. The row ACTOR_STATE_SUSPENDED is empty. CLIENT TIME OF EACH REQUEST request 1 0.358 s request 2 0.091 s request 3 0.067 s request 4 0.062 s request 5 0.068 s request 6 0.080 s after 900 s idle about 0.29 s more than requests 2 to 5: the resume from the golden snapshot 0.067 s, the median of requests 2 to 5 0 0.1 0.2 0.3 0.4 seconds STATE OF MY-COUNTER-1 BETWEEN REQUEST 5 AND REQUEST 6 60 of 60 samples ACTOR_STATE_RUNNING ACTOR_STATE_SUSPENDED 0 samples: no idle timer suspended the actor request 5 request 6, 0.080 s 0 150 300 450 600 750 900 seconds with no request, one state sample every 15 s
Only the first request paid for a resume, and the request after 900 idle seconds was as fast as the others because the actor never stopped. Each bar is the time of one request at the client. The amber bar includes the resume from the golden snapshot, and the dashed line is the median of requests 2 to 5. Each dot in the lower part is one state sample from the 900 seconds with no request.

The sixth request took 0.080 s and returned count 6. A resume would have cost about 0.36 s. The actor was never suspended, and it held its worker for the whole 15 minutes. Section 1.1 names this as the first job that Substrate leaves to you.

Part 2

Start

Install from the release images, create one actor, and prove that it answers before you change a setting.

  1. 2.1Install with ate-setup
  2. 2.2kubectl ate
  3. 2.3Your first actor and kubectl ate proxy
  4. 2.4What the install created

Install with ate-setup

Install from the release images: that path skips the image builds, the Rust compile, and a failure that the upstream quickstart does not mention.

You follow a quickstart on a clean machine. The third command runs for almost three minutes and then stops with an error from Docker about a flag you never typed. We hit that error on our first install, and you do not need to.

When you finish this section, you can install Agent Substrate v1.0.0 on a kind cluster from the release images. You can also say what the build from source needs, and fix the install errors we hit.

What you need

  • Linux or macOS with Docker. On Apple silicon we used Docker Desktop with 10 CPU and 8 GB.
  • Go 1.27. The checkout asks for toolchain go1.27.3, and Go downloads kind for you.
  • git and kubectl.
  • About 13 GB of disk. Our guest used 13 GB in all after this install, with the operating system.

Installer: ate-setup is the Go program in the checkout that installs and removes Agent Substrate. The release has no ate-setup binary, so you run it with go run from a checkout of the tag. The manifests also come from that checkout, so check out the tag that you install.

On Linux we raised two inotify limits before the install, because many pods on one node use many watches:

bash
sudo sysctl -w fs.inotify.max_user_instances=8192 fs.inotify.max_user_watches=1048576

Create the kind cluster

Clone the tag, then run the cluster script from the repository root:

bash
git clone --branch v1.0.0 https://github.com/agent-substrate/substrate.git
cd substrate
hack/create-kind-cluster.sh

The script does more than kind create cluster. Substrate needs three settings that a plain kind cluster does not have:

  • It starts a local image registry named kind-registry on 127.0.0.1:5001.
  • It turns on three Kubernetes preview features: ClusterTrustBundle, ClusterTrustBundleProjection, and PodCertificateRequest. Substrate gives every pod its certificate through them.
  • It looks for /dev/kvm. With KVM, the node can run the microVM sandbox class. Without KVM, only gVisor works.

The script deletes an existing kind cluster with the same name before it creates the new one. The default name is kind. Set KIND_CLUSTER_NAME if you already have a cluster with that name.

Install the control plane from the release images

Tell ate-setup that the target is kind, then deploy:

bash
export ATE_INSTALL_KIND=true
go run ./cmd/ate-setup deploy ate-system \
  --credential-provider='{"name":"k8s.io"}' \
  --ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client \
  --image-repo ghcr.io/agent-substrate/substrate --image-tag v1.0.0

Each flag has a reason:

  • ATE_INSTALL_KIND=true selects the kind profile: the kind overlays, the local registry, and the object store in the cluster. Export it in every shell that runs ate-setup.
  • --credential-provider is required. The value {"name":"k8s.io"} deploys the bundled provider that reads Kubernetes Secrets. Section 6.5 uses it.
  • --ingress-allowed-spiffe-ids lists the clients that the router lets through. With no value, no client reaches any actor. The identity in this command is the one that kubectl ate proxy presents.
  • --image-repo and --image-tag install published images. Each of the two needs the other.

The installer prints every setting first, and then one line for each step. These lines tell you that the images came from the registry and that the install is complete:

outputthe install from the release imagesFull log
>> Downloading kata-static 4.3.1 (amd64)...
>> Downloading cloud-hypervisor v53.0 (cloud-hypervisor-static)...
…
[step]: images: pre-built from ghcr.io/agent-substrate/substrate, tag v1.0.0
…
[step]: Waiting for ATE system components to be ready...
  (rollout status deployment/ate-api-server took 0.005s)
…
  (rollout status daemonset/atelet took 0.003s)
  (rollout status deployment/k8s-credential-provider took 0.003s)
Seven lines from 142. The settings table, the secrets, and the other rollout lines are cut.

The first two lines show a download that the release images do not remove. The installer fetches the guest files of the microVM class from GitHub on the first install from a directory, and it keeps them under bin/microvm-assets. A later install from the same directory skips the download when the files and their version stamp are present. The installer documents --sandbox-classes=gvisor to install gVisor only. We ran that flag for one install, on a cluster with three kind nodes on the second x86 server guest (16 vCPU, 23 GB). The system step took 114 s.

Deploy the counter demo

The demo gives you a WorkerPool, a template, and an atespace to test with:

bash
go run ./cmd/ate-setup deploy demo counter

This step builds two images with ko and pushes them to kind-registry, also on the release path. The release images leave out the demos. The step ends when the golden snapshot of the counter template is ready.

What we measured

Figure 2.1 shows the steps of both x86 installs.

Fig. 2.1install steps from the release images and from sourcechart
Install steps from the release images and from source Two groups of horizontal bars with the seconds of each install step. From the release images on an x86 guest with 16 vCPU: kind cluster 55 s, deploy ate-system 162 s, deploy demo counter 94 s, 311 s in all. Built from source on an x86 guest with 24 vCPU: kind cluster 56 s, deploy ate-system 163 s and failed, the same step again 266 s, deploy demo counter 47 s, 532 s in all. The failed step is rose and stopped on the missing Docker buildx plugin. FROM THE RELEASE IMAGES · X86 GUEST, 16 VCPU 311 s in all kind cluster 55 s deploy ate-system 162 s deploy demo counter 94 s 0 100 200 300 s BUILT FROM SOURCE · X86 GUEST, 24 VCPU 532 s in all kind cluster 56 s deploy ate-system 163 s, exit 1: no buildx the same step again 266 s deploy demo counter 47 s 0 100 200 300 s The source build made the Envoy image with docker buildx and compiled a Rust module. In both installs the demo step built the worker and counter images with ko.
From the release images deploy ate-system took 162 s on one guest, and from source on a larger guest it failed, then took 266 s. Each bar is one step of one install, on an axis in seconds. The two installs ran on two x86 guests of different sizes, so read each group alone. The rose bar is the failed step.

We measured the release-image install once on a second x86 server guest (16 vCPU, 23 GB). The kind cluster took 55 s, deploy ate-system took 162 s, and deploy demo counter took 94 s. The disk held 13 GB afterwards. Raw runs

We measured the build from source once on the x86 server guest (24 vCPU, 47 GB, nested KVM). The first deploy ate-system failed after 163 s. After we installed one package, the same command took 266 s, and the demo took 47 s. The disk held 18 GB afterwards. Raw runs

We measured the release-image install once on an Apple silicon laptop under Docker Desktop. The kind cluster took 119 s, and deploy ate-system took 702 s. The longest wait in its log was 178 s for the object store pod. The kind node held 2.7 GiB of memory after the install. Raw runs

The two x86 installs ran on different guests, so the difference of 104 s between 162 s and 266 s is indicative only. It is not a controlled comparison. What we can state without a number is what each path does. The release-image path skips the image builds and the Rust compile, and the next heading shows both in the source log. The laptop is a third machine on another network, so keep its seconds apart from the seconds of the guests.

What the build from source costs

Leave out --image-repo and --image-tag, and ate-setup builds every image from the checkout. The upstream quickstart shows that form. It lists Go, kubectl, and Docker as the things you need. On Ubuntu 24.04 with the stock Docker package, that list is one item short:

outputthe source install without the Docker buildx pluginFull log
unknown flag: --platform
…
Error: while building the envoy-dataplane image: exit status 125
The docker usage text between the two lines is cut.

The Envoy dataplane image is built from a Dockerfile with docker buildx. The other images are built with ko. On Ubuntu the fix is one package:

bash
sudo apt-get install -y docker-buildx

The second cost is a Rust compile. The Envoy image contains a Rust module for the egress policy. The build clones the Envoy repository and compiles that module:

outputthe Rust module in the source buildFull log
#11 0.323     Updating git repository `https://github.com/envoyproxy/envoy`
…
#11 53.04    Compiling proc-macro2 v1.0.107
…
#11 91.89     Finished `release` profile [optimized] target(s) in 1m 31s
…
#11 DONE 92.2s
Four lines from the build of the Envoy dataplane image. About 90 dependency lines are cut.

That one build step took 92 s of the 266 s. You pay it again when the build cache is empty. Build from source when you change Substrate itself. For every other install, use the release images.

Change the dataplane later

The same command switches a running cluster from Envoy to agentgateway. Add one flag and run it again:

bash
go run ./cmd/ate-setup deploy ate-system \
  --credential-provider='{"name":"k8s.io"}' \
  --ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client \
  --image-repo ghcr.io/agent-substrate/substrate --image-tag v1.0.0 \
  --atenet-dataplane=agentgateway

We measured this switch once on the second x86 server guest (16 vCPU, 23 GB). It took 32 s and needed no new cluster. Section 6.6 compares the two dataplanes. Raw runs

If the install fails

What you seeCauseFix
unknown flag: --platform, then exit status 125A build from source without the Docker buildx pluginInstall the plugin, or install from the release images
The installer stops and prints retry with: ate-setup deploy ate-system --config and a fileA step failed, and the installer saved the settings of the runFix the cause, then run the printed command. Start it with go run ./cmd/ate-setup, because the release has no ate-setup binary
kubectl shows pods you do not knowThe kubeconfig context is not the one of your kind clusterRun kubectl config use-context kind-kind. With your own KIND_CLUSTER_NAME, the context is kind- and that name

The last row happened to us on the laptop. After a restart of Docker Desktop, kubectl pointed at the cluster that Docker Desktop includes, and our first test ran there.

To remove everything, delete the cluster and the registry:

bash
./hack/delete-kind-cluster.sh

Old guides for v0.x use install scripts that no longer exist. Section 8.4 lists what changed.

kubectl ate

kubectl ate talks to ate-api-server and reads Postgres, and without --token-file every caller acts as one shared identity.

You type kubectl get actors on a cluster that runs Agent Substrate, and Kubernetes answers that it has no such resource. The actor exists. You asked the wrong server. Actors, templates, and workers are rows in the Substrate database, and one plugin reads them.

When you finish this section, you can install kubectl ate and say how each command reaches the control plane. You can also explain the warning that it prints on every call.

Install the plugin

Plugin: kubectl-ate is one binary. When it is on your PATH, kubectl runs it for every command that starts with kubectl ate.

The release publishes the binary for Linux and macOS, on amd64 and arm64. There is no Windows build. Download the file and its checksum, check it, and install it:

bash
BASE=https://github.com/agent-substrate/substrate/releases/download/v1.0.0
curl -LO $BASE/kubectl-ate-linux-amd64
curl -LO $BASE/kubectl-ate-linux-amd64.sha256
shasum -a 256 -c kubectl-ate-linux-amd64.sha256
mkdir -p ~/.local/bin
install -m 0755 kubectl-ate-linux-amd64 ~/.local/bin/kubectl-ate

On a Mac with Apple silicon, the file is kubectl-ate-darwin-arm64. If you have the checkout and Go, you can build the plugin from the tag:

bash
go install ./cmd/kubectl-ate

kubectl finds the plugin only on your PATH. Check that ~/.local/bin is on it, or the directory $(go env GOPATH)/bin when you use go install.

We measured the build once on an x86 server guest with 16 vCPU and 23 GB. go install took 5 s after the install of the control plane, because the Go modules were already on disk. In our runs we always built the plugin from the tag. We checked that both release files exist, and we did not time the download. Raw runs

Check the version before you go on:

bash
kubectl ate --version

How a command reaches the control plane

kubectl ate does not ask the Kubernetes API for actors. It uses your kubeconfig to open a port-forward to the Service api in the namespace ate-system. Without --token-file, it also uses the kubeconfig to request a token for the shared ate-client ServiceAccount. Then it sends a gRPC request through that port-forward to ate-api-server, and ate-api-server answers from Postgres. Figure 2.2 shows that path beside the path of a plain kubectl command.

Fig. 2.2where kubectl ate and kubectl read fromflow
Where kubectl ate and kubectl read from Two rows. In the top row kubectl ate get actors opens a port-forward through kube-apiserver to the Service api on port 443, where ate-api-server answers from Postgres. The token it sends belongs to the shared identity system:serviceaccount:ate-system:ate-client unless you pass --token-file. In the bottom row kubectl get workerpools asks kube-apiserver, which answers from etcd, because WorkerPool, SandboxConfig, and CSIDriverConfig are the only custom resources. ATESPACES, TEMPLATES, ACTORS, WORKERS, POLICIES kubectl ate get actors -A kube-apiserver port-forward ate-api-server Service api:443 Postgres rows acting as system:serviceaccount:ate-system:ate-client the shared identity of every caller that passes no --token-file WORKERPOOL, SANDBOXCONFIG, CSIDRIVERCONFIG kubectl get workerpools -A kube-apiserver ate.dev/v1 etcd the 3 CRDs kubectl get actor finds nothing: an actor is a row in Postgres, not a custom resource. With --endpoint, kubectl ate skips the port-forward and dials ate-api-server directly.
kubectl ate reads atespaces, templates, actors, and workers from Postgres through ate-api-server, and kubectl reads only three custom resources from etcd. The top row is every kubectl ate command except proxy. The bottom row is plain kubectl. The dashed box is the identity that the top row uses by default.

Two facts follow from the figure.

First, each command opens its own port-forward. So kubectl ate needs a working Kubernetes API server, and your account needs the right to port-forward into ate-system. With kube-apiserver down, a command fails like this:

outputa kubectl ate command while kube-apiserver is downFull log
Error: failed to connect to ate-api-server: getting api service: Get "https://127.0.0.1:44015/api/v1/namespaces/ate-system/services/api": EOF

Actors kept serving requests during that outage. Only the CLI and the proxy failed. Section 2.3 has the numbers.

Second, the flag --endpoint skips the port-forward. A program inside the cluster passes the address of ate-api-server and dials it directly. Its pod must be in a namespace with the label ate.dev/api-access=true. We did not set that label ourselves, and we did see the network rule behind it in the kagent run. A pod in default could not reach the API, and a pod in kagent could. Section 5.1 has that probe.

The shared identity warning

Every command in our recordings starts with the same line:

outputthe first actor, created without a token fileFull log
Warning: no --token-file given; acting as the shared identity system:serviceaccount:ate-system:ate-client rather than as yourself. Pass --token-file to act as yourself.
ATESPACE           NAME           TEMPLATE                   STATE                   WORKER POD   WORKER IPS   VERSION   AGE
ate-demo-counter   my-counter-1   ate-demo-counter/counter   ACTOR_STATE_SUSPENDED   <none>                    1         0s

Shared identity: the ServiceAccount ate-client in ate-system. When you pass no token, the plugin asks Kubernetes for a token of that ServiceAccount and sends it to ate-api-server.

The warning is correct, and you must not ignore it on a shared cluster. Every person and every script that can port-forward into ate-system is the same caller to Substrate. An access policy cannot tell them apart, because it sees one name.

On a kind cluster on your own machine, the shared identity is fine. On a cluster with more than one team, give each caller a token and pass it with --token-file. Section 5.1 shows which token to use, and section 5.2 limits what a caller can do in an atespace.

Our scripts removed the line from their output with grep -v '^Warning: no --token-file'. Do that in a script that parses a table. Do not do it for people.

The commands of the first day

An actor is addressed by its atespace and its name, so most commands need -a with an atespace. No default atespace exists.

CommandWhat it does
kubectl ate get actors -a ate-demo-counterlists the actors of one atespace
kubectl ate get actors -Alists the actors of all atespaces
kubectl ate get actor my-counter-1 -a ate-demo-counter -o yamlprints one actor with its snapshots
kubectl ate create actor my-counter-1 -a ate-demo-counter --template countercreates an actor from a template
kubectl ate suspend actor my-counter-1 -a ate-demo-counterwrites a snapshot and frees the worker
kubectl ate resume actor my-counter-1 -a ate-demo-counterrestores the actor onto a worker
kubectl ate revert actor my-counter-1 -a ate-demo-counterreturns a crashed actor to its last durable snapshot
kubectl ate logs actor my-counter-1 -a ate-demo-counterprints the logs of the actor on its worker
kubectl ate get workerslists the workers with their used CPU and memory
kubectl ate proxy actor my-counter-1 -a ate-demo-counterforwards a local port to the actor

-o json and -o yaml print the full object. Use them when you debug, because the table hides the snapshots. This is the new actor as JSON:

jsonthe status of a new actorFull log
  "status": {
    "lastAssignedGeneration": 1,
    "snapshots": [
      {
…
        "durableSnapshot": {
…
          "state": "SNAPSHOT_STORAGE_STATE_COMPLETED"
        },
        "fidelity": "SNAPSHOT_FIDELITY_MEMORY",
        "generation": 1,
        "owner": "SNAPSHOT_OWNER_TAG",
…
    "state": "ACTOR_STATE_SUSPENDED"
Cut from the actor object. The metadata and the sandbox version attributes are cut.

The actor has never run, and it already has one snapshot. The owner is a tag, and the tag belongs to the template. Section 3.1 explains that snapshot. The command reference lists every command and flag.

Three mistakes we made

-A on workers. A worker belongs to a Kubernetes namespace and not to an atespace. The flag that works for actors fails for workers:

output-A is not a flag of get workerFull log
Error: unknown flag "-A"
…
  -a, --atespace string        filter worker pods hosting actors in a specific atespace
…
  -n, --namespace string       scope output to a specific Kubernetes namespace
The rest of the usage text is cut.

Run kubectl ate get workers with no flag to see all workers.

kubectl get for an actor. kubectl get actor and kubectl get actortemplate return nothing, because those objects are not custom resources. kubectl get workerpool does work, because a WorkerPool is one.

A flag from an old guide. Guides for v0.x create an actor with --template-ref. At v1.0.0 the flag is --template. Section 8.4 lists the other changes.

Your first actor and kubectl ate proxy

kubectl ate proxy is a tool for your own machine: it tunnels through the Kubernetes API server, so it fails when that server is down while actor traffic continues.

The install is done, and you want to see an actor answer. The quickstart gives you two commands and a curl. They work. Then you put the same proxy command in a test script. The control plane of the cluster restarts, and every request times out while the actors are fine.

When you finish this section, you can create an actor, reach it through kubectl ate proxy, and suspend it. You can also say why the proxy must not carry real traffic, and what a real client needs in its place.

Create the actor

The counter demo created an atespace named ate-demo-counter and a template named counter in it. Create an actor from that template:

bash
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter

The command prints the new actor. In our run the state was ACTOR_STATE_SUSPENDED, the worker pod was <none>, and the version was 1. Section 2.2 shows that output.

A new actor uses no worker. Nothing was started. The create call wrote one row to Postgres and pointed the actor at the golden snapshot of its template. In one of our tests we created 131 actors on a pool of three workers before any of them ran.

Start the proxy

Start the proxy in a terminal of its own. It stays in the foreground:

bash
kubectl ate proxy actor my-counter-1 -a ate-demo-counter
outputthe proxy after it startsFull log
Proxying 127.0.0.1:8000 -> ate-demo-counter/my-counter-1:80

With no port argument, the proxy forwards local port 8000 to port 80 of the actor. To choose the ports, add pairs in the form local:remote, such as 8080:80. The proxy listens on 127.0.0.1 only, unless you pass --address.

Proxy: kubectl ate proxy actor is a local listener that forwards TCP to one actor through atenet-router. It does three things for you. It opens a port-forward to the router. It gets a client certificate for the identity atenet-client. It names the target actor to the router.

The install must allow that identity. This is why the install command in section 2.1 passes --ingress-allowed-spiffe-ids with the atenet-client name. Without that flag, the router lets no client through, and the proxy cannot reach any actor.

Send requests

In a second terminal, send a request:

bash
curl -X POST http://localhost:8000/

Send it a few times. This is our recording of the first three requests:

outputthe first three requestsFull log
2026-11-12T10:14:21.837Z	call-1	200	0.358379	preserved memory count: 1 | preserved file counter: 1 | preserved rootfs counter: 1
2026-11-12T10:14:21.944Z	call-2	200	0.090770	preserved memory count: 2 | preserved file counter: 2 | preserved rootfs counter: 2
2026-11-12T10:14:22.030Z	call-3	200	0.066903	preserved memory count: 3 | preserved file counter: 3 | preserved rootfs counter: 3
Three lines of the trace. The columns are the time, the event, the status, the seconds, and the body.

The counter demo keeps three counts: one in memory, one in a file, and one in the root filesystem. All three rise together here. They matter later, when a snapshot keeps less than the memory. Section 3.4 uses them.

The first request resumed the actor. Now it holds a worker:

outputthe worker that runs my-counter-1Full log
NAME                                   POOL      STATE                 ACTORS   CPU(MILLICORES)   MEMORY        POD                                         AGE
ec15263e-4c0f-45eb-adc1-53f5f60c2331   counter   WORKER_STATE_ACTIVE   1/1000   1000/1000         512Mi/960Mi   ate-demo-counter/counter-7cc7dcd596-ffxwt   74s
The warning line is cut.

Read the CPU(MILLICORES) column. This one actor takes 1000 of the 1000 millicores of its worker. The actor limit of 1000 in the ACTORS column is not the limit that binds. The CPU is. So the demo pool of three workers runs three counter actors at one time, and a fourth must wait. Section 4.5 sizes a pool from these columns.

Suspend it yourself

Stop sending requests and watch the actor. It stays in ACTOR_STATE_RUNNING. Substrate does not suspend it.

We measured this on an x86 server guest with 24 vCPU and 47 GB, in one run. For 900 s we sent no request and read the state every 15 s. All 60 samples were ACTOR_STATE_RUNNING. The next request took 0.080 s and returned count 6. Raw runs

To free the worker, suspend the actor:

bash
kubectl ate suspend actor my-counter-1 -a ate-demo-counter

We measured the suspend on the same guest, once. The command returned in 0.40 s, and the state was ACTOR_STATE_SUSPENDED. The next request through the proxy took 0.36 s and returned the next count, so the memory came back. Raw runs

This default suspend is asynchronous. The new snapshot is on the node and is not yet in the object store. Section 3.3 explains the two modes, and section 3.5 shows what you lose when the node copy disappears.

The proxy depends on the Kubernetes API server

The proxy reaches the router through a Kubernetes port-forward. Every byte passes kube-apiserver. A client inside the cluster does not need that hop. Figure 2.3 puts the two paths side by side.

Fig. 2.3two paths to one actor while kube-apiserver is downcomparison
Two paths to one actor while kube-apiserver is down Two columns. On the left, curl sends to kubectl ate proxy, which tunnels through kube-apiserver to atenet-router port 8444 and then to the actor. With kube-apiserver down for 86 seconds, 0 of 81 requests on this path were served. On the right, a pod with a client certificate sends to atenet-router port 443 directly, and 86 of 86 requests were served in the same 86 seconds. A · KUBECTL ATE PROXY ACTOR B · A POD WITH A CLIENT CERTIFICATE curl POST http://localhost:8000/ kubectl ate proxy actor on your machine kube-apiserver down for 86 s atenet-router:8444 mTLS CONNECT e-prober pod ate-target-actor: lab-e/e-run atenet-router:443 checks the SPIFFE ID worker pod CONNECT to port 8443 actor e-run ACTOR_STATE_RUNNING 0 of 81 requests served 86 of 86 requests served every one timed out at the client kube-apiserver is not on this path
With kube-apiserver down for 86 s the proxy served no request, and a pod that called the router served every one. Column A is kubectl ate proxy on the machine of the operator. Column B is a pod in the cluster with a client certificate. Both sent one request each second to the same running actor.

We measured both paths on an x86 server guest with 16 vCPU and 23 GB, in one run with one request each second. We took kube-apiserver away for 86 s. Through the proxy, 0 of 81 requests in that time were served, and each one ran into the 9 s limit of the client. From the pod, 86 of 86 were served. The proxy kept failing for 21 s after kube-apiserver was ready again. Raw runs

outputthe kube-apiserver outage in our analysisFull log
== T3 kube-apiserver: down at +0 s, restore action at +86 s, dependency ready at +91 s
  proxy/running (VM, kubectl ate proxy)
     codes from -10 s: 200x9 000x105 200x59
     first failure at +0 s, last failure at +112 s (= +26 s from restore action, +21 s from dependency ready); failed 105 of 173
     failure latency p50 9.00 s max 9.00 s
     105x code=000 body=''
  incluster/running (pod, mTLS to router)
     codes from -10 s: 200x186
     no failures
The block for the third outage. The counts start 10 s before the outage and end 90 s after it.

So do not judge the health of Substrate with the proxy, and do not put the proxy between a product and its actors. The proxy tells you about your path to the cluster. A pod that calls the router tells you about Substrate.

What a real client needs

A real client is a pod. It needs three things, and the pod we used for the test shows all of them.

yamlthe pod that called the routerFull log
kind: Pod
…
metadata: {name: e-prober, namespace: ate-system, labels: {app: e-prober}}
…
  serviceAccountName: atenet-client
…
      - podCertificate: {credentialBundlePath: credential-bundle.pem, keyType: ECDSAP256, signerName: podidentity.podcert.ate.dev/identity}
…
      - clusterTrustBundle: {labelSelector: {matchLabels: {podcert.ate.dev/canarying: live}}, path: trust-bundle.pem, signerName: servicedns.podcert.ate.dev/identity}
The container and the volume mounts are cut.
  1. An allowed identity. The pod runs as the ServiceAccount atenet-client in ate-system. That is the identity in --ingress-allowed-spiffe-ids. The install does not create this ServiceAccount, so we created it.
  2. A client certificate. The podCertificate volume asks Kubernetes for a certificate with that identity. The clusterTrustBundle volume holds the CA that verifies the router.
  3. The target header. Each request names its actor in the header ate-target-actor, as the atespace, a slash, and the actor name.

The request of that pod was this command:

bash
curl --cacert /run/ca/trust-bundle.pem \
  --cert /run/id/credential-bundle.pem --key /run/id/credential-bundle.pem \
  -X POST -H 'ate-target-actor: lab-e/e-run' \
  https://atenet-router.ate-system.svc:443/

Any listed client can reach any actor through the router. The allowlist says who can enter. It does not say which actor each client can call. Section 5.1 and section 6.1 cover the identity and the router.

What the install created

The system install created 15 running pods in three namespaces and three custom resources, and it put every atespace, template, actor, and worker in Postgres, where kubectl cannot see them.

The install ended with no error, and kubectl get pods -A now prints a page of names you did not choose. One of them will be in an error message next week. You want to know what each pod does, which ones have a second replica, and where your actors are stored.

When you finish this section, you can name the job of each pod that the install created. You can also say which objects live in etcd and which live in Postgres, and which pods run as a single replica.

The pods

This is the namespace ate-system after our install from source. The install from the release images created the same pods.

outputthe pods of ate-systemFull log
NAMESPACE                          NAME                                         READY   STATUS      RESTARTS   AGE …
ate-system                         ate-api-server-696d95dd66-9q7f8              2/2     Running     0          117s …
ate-system                         ate-api-server-696d95dd66-t85dd              2/2     Running     0          117s …
ate-system                         ate-controller-7479ff54f-jrrtc               1/1     Running     0          114s …
ate-system                         ate-idp-server-7c46c85d94-66sf2              1/1     Running     0          116s …
ate-system                         ate-idp-server-7c46c85d94-cfbt4              1/1     Running     0          116s …
ate-system                         atelet-sqzvr                                 2/2     Running     0          113s …
ate-system                         atenet-egress-78f759fcb8-74d4m               3/3     Running     0          76s …
ate-system                         atenet-router-86454b57f5-7vtp5               2/2     Running     0          112s …
ate-system                         k8s-credential-provider-8df9859f8-jcfll      1/1     Running     0          89s …
ate-system                         postgres-0                                   2/2     Running     0          7m13s …
ate-system                         rustfs-54c86945fb-w9pnn                      1/1     Running     0          109s …
ate-system                         rustfs-bucket-init-bvq6z                     0/1     Completed   0          109s …
Twelve lines of kubectl get pods -A -o wide. The IP and node columns of each line are cut.

Figure 2.4 groups all the pods by namespace and gives each one the colour of its role.

Fig. 2.4what the install created on kind-control-planelayers
What the install created on kind-control-plane The namespace ate-system holds 11 running pods and one finished Job: ate-api-server, ate-controller, ate-idp-server, atelet, atenet-router, atenet-egress, k8s-credential-provider, postgres-0, rustfs, and rustfs-bucket-init. The namespace podcertificate-controller-system holds podcertificate-controller. The namespace ate-demo-counter holds the 3 worker pods of the WorkerPool counter. The namespace otel-system holds jaeger, opentelemetry-collector, and prometheus. Two boxes at the bottom say where objects live: etcd holds the three custom resources WorkerPool, SandboxConfig, and CSIDriverConfig, and Postgres holds atespaces, templates, actors, workers, and policies. ATE-SYSTEM · 11 RUNNING PODS, 1 FINISHED JOB ate-api-server 2 pods, 2 containers each ate-controller 1 pod ate-idp-server 2 pods atelet DaemonSet, 2 containers atenet-router 1 pod, 2 containers atenet-egress 1 pod, 3 containers k8s-credential-provider 1 pod postgres-0 StatefulSet, 2 containers rustfs object store, 1 pod rustfs-bucket-init Job, Completed PODCERTIFICATE-CONTROLLER-SYSTEM podcertificate-controller signs pod certificates, 1 pod ATE-DEMO-COUNTER counter WorkerPool, 3 worker pods image ateworker-gvisor OTEL-SYSTEM jaeger opentelemetry-collector prometheus KUBE-SYSTEM Kubernetes v1.37.0 8 pods, not from Substrate WHERE THE OBJECTS LIVE etcd: 3 custom resources WorkerPool, SandboxConfig, CSIDriverConfig Postgres: all other objects atespaces, templates, actors, workers, policies
The install put the control plane, the dataplane, the node agent, Postgres, and the object store in one namespace on one node. Each box is one workload from our install. Blue is the control plane, indigo the dataplane, violet workers and sandboxes, teal durable state, and grey is outside Substrate.

Each workload has one job:

WorkloadPodsJob
ate-api-server2The API and the scheduler. It serves kubectl ate and the router, and it keeps all its state in Postgres
ate-controller1Turns each WorkerPool into a Deployment of worker pods, and registers each worker pod with ate-api-server
ate-idp-server2Publishes the keys that verify the JWT of an actor. Section 5.3 uses it
atelet1 on each nodeThe node agent. It pulls images, restores and checkpoints sandboxes, keeps snapshots on the node, and uploads them
atenet-router1Ingress. It checks the client certificate, asks ate-api-server where the actor is, and forwards the request
atenet-egress1Egress. Every outbound connection of every actor goes through it
k8s-credential-provider1Reads Kubernetes Secrets for credential injection
postgres-01The database of Substrate
rustfs1The object store for snapshots on kind. A cloud install uses a bucket
rustfs-bucket-inita finished JobCreated the bucket ate-snapshots once

One more pod runs in its own namespace. podcertificate-controller signs the certificates that the pods use to prove who they are. It needs the three Kubernetes preview features that the cluster script turned on.

What has one replica

Count the pods in the listing. ate-api-server and ate-idp-server have two. Everything else has one.

Fig. 2.5the pods of ate-system, one object for each podlayers
The pods of ate-system on kind-control-plane An isometric drawing of the namespace ate-system as one floor tile on the node kind-control-plane, which carries the Kubernetes mark. The tile holds 11 running pods and 1 finished Job, one object for each pod. Two ate-api-server towers and two ate-idp-server towers stand side by side, and one ate-controller tower stands alone, all with the Agent Substrate mark. postgres-0 is a cylinder and rustfs is a bucket. atenet-router and atenet-egress are gateway units with the Envoy mark. atelet and k8s-credential-provider are small units, and the finished Job rustfs-bucket-init is a dashed outline. ATE-SYSTEM · 11 RUNNING PODS, 1 FINISHED JOB ate-api-server 2 pods, 2 containers each ate-idp-server 2 pods ate-controller 1 pod postgres-0 StatefulSet, 2 containers atenet-router 1 pod, 2 containers atelet DaemonSet, 2 containers k8s-credential-provider 1 pod rustfs object store, 1 pod atenet-egress 1 pod, 3 containers rustfs-bucket-init Job, Completed namespace ate-system node kind-control-plane
In ate-system only ate-api-server and ate-idp-server run two pods, and every other workload, the router and Postgres among them, runs one. Each object is one pod of our install, on the tile of its namespace and the slab of its node. A tower is a server, and a low unit with ports is a gateway. The cylinder is Postgres, the bucket is the object store, and the dashed outline is the finished Job. A mark names the project that a program comes from.

That matters for three of them. Every request to every actor passes atenet-router, and ate-api-server reads Postgres for each one. Section 1.2 shows the path. So one router pod and one Postgres pod are in front of all your actors. The bundled Postgres has no second replica and no failover.

For a kind cluster on your machine, that is fine. For anything that people depend on, read section 8.1 before you go further. It covers an external database and what a Postgres outage does to requests.

Three custom resources

The install added three custom resources to Kubernetes:

outputthe custom resources of Agent SubstrateFull log
NAME                       SCOPE        VERSIONS      CREATED AT
csidriverconfigs.ate.dev   Cluster      v1(storage)   2026-11-12T10:05:33Z
sandboxconfigs.ate.dev     Cluster      v1(storage)   2026-11-12T10:05:33Z
workerpools.ate.dev        Namespaced   v1(storage)   2026-11-12T10:05:33Z

WorkerPool: a namespaced object that asks for a number of worker pods of one sandbox class. It is the only Substrate object that you manage with plain kubectl day to day. Section 4.1 covers it.

SandboxConfig describes a sandbox class for the whole cluster. The install created two of them: gvisor-default and microvm. CSIDriverConfig belongs to a preview feature for volumes, and we did not use it.

Everything else is a row in Postgres: atespaces, templates, actors, tags, workers, egress policies, and access policies. So kubectl get does not list them, a backup of etcd does not hold them, and no Kubernetes RBAC rule names them. To back up your actors, back up Postgres and the object store. A snapshot from a default suspend reaches the object store about 5 minutes later, so an earlier backup does not hold it. Section 3.4 has the timers. Section 3.6 and section 5.2 cover names and access.

The demo namespace

deploy demo counter created the namespace ate-demo-counter with one WorkerPool named counter. ate-controller turned it into three worker pods. Each pod runs the image ateworker-gvisor, which holds the gVisor sandbox.

The demo also created two things that kubectl cannot show: the atespace ate-demo-counter and the template counter. Read them with the plugin:

bash
kubectl get workerpools -A
kubectl ate get atespaces
kubectl ate get actor-templates -a ate-demo-counter

The atespace and the Kubernetes namespace have the same name here. They are two objects. An atespace is a Substrate name, and nothing ties it to a namespace.

A worker pod is not an ordinary pod. It runs as root with the SYS_ADMIN and NET_ADMIN capabilities, because it starts sandboxes. Put WorkerPools on nodes that you trust for that. Section 4.2 covers the sandbox classes.

The telemetry namespace

The kind profile also installs otel-system with three pods: an OpenTelemetry collector, Prometheus, and Jaeger. They are not part of Substrate. They receive its metrics and traces.

They are useful on day one. Add --trace to any kubectl ate command, forward the Jaeger port, and open the trace:

bash
kubectl port-forward -n otel-system svc/jaeger 16686:16686 &
kubectl ate get actor my-counter-1 -a ate-demo-counter --trace

What it costs on one machine

We measured the size of the release-image install once on an x86 server guest with 16 vCPU and 23 GB. Docker held 2.1 GB of images and 4.8 GB of volumes. The guest used 13 GB of disk in all, with the operating system and the Go caches. Raw runs

We measured the memory once on an Apple silicon laptop under Docker Desktop. The kind node held 2.7 GiB right after the install, with no actor. Raw runs

The install also left two things outside the cluster. The first is a second container, the local registry:

outputthe containers on the hostFull log
kind-control-plane kindest/node:v1.37.0
kind-registry registry:3

The second is a file. ate-setup records the settings of each install in the cache directory of your user, one file for each kubeconfig context. That is ~/.cache/ate-setup/installs/ on Linux and ~/Library/Caches/ate-setup/installs/ on macOS, and the flag --record-dir names another place. Keep that file. It tells you later which flags the running cluster was installed with.

Part 3

Actors

An actor is a snapshot that runs for a while, so its speed and its safety both depend on where that snapshot is.

  1. 3.1ActorTemplate and the golden snapshot
  2. 3.2Actor and its seven states
  3. 3.3SuspendActor and ResumeActor
  4. 3.4Snapshot fidelity, and where a snapshot lives
  5. 3.5CRASHED and RevertActor
  6. 3.6Atespaces and names

ActorTemplate and the golden snapshot

Substrate snapshots each ActorTemplate once, and an actor created after that starts suspended from the snapshot, so its first call pays a restore.

Your agent image takes several seconds to import its libraries and load its tools. With a Deployment, every new pod pays those seconds again. Substrate moves that cost to the template. It starts the image once, takes a snapshot, and creates every new actor from that snapshot. The cost does not disappear: the first request to a new actor restores the snapshot, and the restore grows with the memory in it.

When you finish this section, you can write an ActorTemplate, check that its golden snapshot exists, and predict the first-call time of a new actor.

The template we used

ActorTemplate: the description of an actor: its containers, its resource limits, its volumes, the worker pools that it can run on, and its snapshot settings. A template belongs to one atespace, and ate-api-server stores it in Postgres. It is not a Kubernetes object, so kubectl get does not list it.

This is one of the five templates of our resume tests. The actor is a small Go server that holds 200 MiB of random bytes and counts its requests.

yamlan ActorTemplate that holds 200 MiBFull log
metadata:
  atespace: lab-c
  name: ballast-200
workerSelector:
  matchLabels:
    workload: ballast
containers:
- name: ballast
  image: localhost:5001/ballast@sha256:93dba82ad560e7fc4ccb2a33a63d64713b519a7895ba3fd98130ce789eab7fd3
  command:
  - /ballast
  - --mb=200
  wakeupProbe:
    httpGet:
      path: /readyz
      port: 80
resources:
  limits:
    cpuMillicores: 1000
    memoryBytes: 478150656
snapshotConfig:
  preferredFidelity: SNAPSHOT_FIDELITY_MEMORY
The complete file. We created it with kubectl ate create actor-template -f.

Five fields decide how the actor behaves.

  • image must name a digest, and a tag alone is refused. Ours is a scratch image from a plain docker build, with no change for Substrate.
  • resources.limits needs both values. The scheduler books them against the capacity of a worker, which section 4.1 explains.
  • workerSelector matches the labels of a WorkerPool object. Section 3.6 shows why the namespace of the pool does not count.
  • wakeupProbe is a readiness check that runs on every resume. The actor becomes ACTOR_STATE_RUNNING only after the probe passes.
  • preferredFidelity says how much state a snapshot keeps. Section 3.4 compares the three levels.

A template is immutable, and the API has no call that updates one. For new code you create a new template, and then you move each suspended actor with kubectl ate update actor --template.

We ran that move with two templates that differ in the image. On a running actor the update is refused.

outputa template change on a running actorFull log
…Error: failed to update actor: rpc error: code = FailedPrecondition desc = actor must be ACTOR_STATE_SUSPENDED to change its actor template (got: ACTOR_STATE_RUNNING) 
One line of the trace. The time and the event name at the start of the line are cut.

On the suspended actor the update was accepted, and the next call ran the new image. The count in memory started again at 1, and so did the count on the root filesystem. The count on the durableDir volume continued at 4. So this move kept the durableDir volume and dropped the old memory snapshot. We did not test a move with an external volume. Suspend first, and expect a cold start of the new image on the next call.

The golden snapshot is built once

Golden snapshot: the snapshot of one temporary actor that Substrate starts from a new template. Substrate stores it under a tag, the golden tag, and every new actor of that template starts from that tag.

ate-api-server builds the golden snapshot in four steps.

  1. It creates a temporary golden actor in the atespace of the template, and it resumes that actor.
  2. It waits until the actor is ready. If a container has no wakeupProbe, it waits 20 seconds more.
  3. It suspends the actor at the preferredFidelity of the template, and it copies the snapshot to a published tag.
  4. It deletes the golden actor, and it writes the tag into the status of the template.

The log of ate-api-server on our kagent cluster shows all four steps for one template.

outputthe golden actor of one template, from create to deleteFull log
2026-11-12T10:16:44.165402326Z	dd7b8625-0d96-4d7d-89da-4ec9cc3b914a	create	suspended
2026-11-12T10:16:44.178659651Z	dd7b8625-0d96-4d7d-89da-4ec9cc3b914a	resume	resuming
2026-11-12T10:16:54.017864285Z	dd7b8625-0d96-4d7d-89da-4ec9cc3b914a	resume	running
2026-11-12T10:16:54.03004179Z	dd7b8625-0d96-4d7d-89da-4ec9cc3b914a	suspend	suspending
2026-11-12T10:16:54.180722259Z	dd7b8625-0d96-4d7d-89da-4ec9cc3b914a	suspend	suspended
2026-11-12T10:16:54.238242923Z	dd7b8625-0d96-4d7d-89da-4ec9cc3b914a	delete	deleting
2026-11-12T10:16:54.328177186Z	dd7b8625-0d96-4d7d-89da-4ec9cc3b914a	delete	deleted
The first seven lines of the file. The columns are the time, the uid of the actor, the operation, and the new state.

The timestamps show a resume of 9.84 seconds, because the golden actor is the one actor that boots the image from zero. The suspend took 0.15 seconds. That template had a files-only fidelity, so the snapshot held no memory.

The build pays the full start of your application, once.

We measured one template for a Python server on the x86 server guest (24 vCPU, 47 GB, nested KVM), with n=1. The server builds a dict of one million values at start. The golden tag was ready 39.3 seconds after create actor-template. The process was 32.9 seconds old at its first request, so most of that time was the application start under gVisor. A new actor of that template then answered its first call in a median of 0.63 seconds, with five new actors. Section 3.3 shows the server. Raw runs

The golden tag does not exist at the moment the template does. In our resume tests we listed the five templates 21 seconds after we created them.

outputfive templates, 21 seconds after createFull log
ATESPACE   NAME           GOLDEN TAG                             ERROR   AGE
lab-c      ballast-0      e32029d7-3d9b-4212-8953-e88d7cede5b4           21s
lab-c      ballast-1000                                                  20s
lab-c      ballast-200    b9f97b82-b187-419d-8789-6c8c7b8a6c8c           20s
lab-c      ballast-50     0382e2fc-99db-461a-8845-a90d93484443           21s
lab-c      ballast-500    2fa7aaf9-1fe8-4076-9350-af27ba338a6b           20s
The complete file. The 1,000 MiB template has no golden tag yet.

All five had a tag when we looked again at 118 seconds. Wait for the GOLDEN TAG column before you create actors. An actor that you create earlier records no snapshot. It boots from the image on its first call, and it keeps that cold start until its own first suspend.

We ran that race. We created an actor directly after its template, and create actor succeeded.

outputan actor created before the golden snapshot existsFull log
2026-11-12T12:49:47.365Z	rc1-state	ACTOR_STATE_SUSPENDED	-	
2026-11-12T12:49:48.542Z	rc1-first-call	200	0.983729	tag=v3 count=1 mb=200 fill=random sum=26936 rootfs=-1 data=-1 pid=1 process_age_s=0.8 child[none]
…
2026-11-12T12:50:00.900Z	golden-ready	waited 11.137 s more
2026-11-12T12:50:01.888Z	rc2-state-new	ACTOR_STATE_SUSPENDED	-	SNAPSHOT_OWNER_TAG:SNAPSHOT_FIDELITY_MEMORY:SNAPSHOT_STORAGE_STATE_COMPLETED:gen1
Four lines of the trace. The actor rc1 is early, and the actor rc2 was created after the golden tag. The lines between them are cut.

We measured this once on the x86 server guest (24 vCPU, 47 GB, nested KVM), with a 200 MiB actor. The early actor was ACTOR_STATE_SUSPENDED with an empty snapshot list. Its first call was a cold start and took 0.98 seconds. The golden tag was ready 11 seconds later, and the first call of an actor created after that took 0.93 seconds. Five more early actors of another template still had no snapshot 25 seconds later. Raw runs

So an early actor never adopts the golden snapshot. It gets its own snapshot at its first suspend. Our actor starts in under a second, so the two first calls cost the same. With an application that needs 30 seconds to start, the early actor pays 30 seconds on its first call.

You can turn the golden snapshot off with snapshotConfig.goldenSnapshotConfig.mode: GOLDEN_SNAPSHOT_MODE_DISABLED. Every new actor then boots from the image.

A new actor starts suspended

kubectl ate create actor writes one row in Postgres and touches no worker, so its time does not depend on the template. The new actor is suspended. It already has a snapshot when the template had its golden tag at that moment.

jsonthe actor my-counter-1 directly after createFull log
{
  "actorTemplate": {
    "atespace": "ate-demo-counter",
    "name": "counter"
  },
  "metadata": {
    "atespace": "ate-demo-counter",
    "createTime": "2026-11-12T10:13:40.987568669Z",
    "name": "my-counter-1",
    "uid": "6f80f612-7547-4f5b-b3ab-831595621806",
    "updateTime": "2026-11-12T10:13:40.987568669Z",
    "version": "1"
  },
  "status": {
    "lastAssignedGeneration": 1,
    "snapshots": [
      {
        "actorTemplateUid": "71a3a39c-5f37-4d6e-937f-9c314fea7668",
        "durableSnapshot": {
          "object": {
            "snapshotUri": "gs://ate-snapshots/atespaces/ate-demo-counter/tags/ad19ee28-1519-432a-bcc5-40cf99cce3ca"
          },
          "state": "SNAPSHOT_STORAGE_STATE_COMPLETED"
        },
        "fidelity": "SNAPSHOT_FIDELITY_MEMORY",
        "generation": 1,
        "owner": "SNAPSHOT_OWNER_TAG",
        …
    "state": "ACTOR_STATE_SUSPENDED"
  }
}
The sandboxRuntime block and the end of the file are cut.

Read three fields. state is ACTOR_STATE_SUSPENDED, so no sandbox exists yet. owner is SNAPSHOT_OWNER_TAG, so the actor borrows the snapshot of the golden tag and owns none. The URI has a tags segment, and it does not hold the uid of the actor. The actor writes its own snapshot at its first suspend.

Fig. 3.1from ActorTemplate to the first callflow
From ActorTemplate to the first call of a new actor Two rows. The top row happens once per template: the template lab-c/ballast-200 leads to a temporary golden actor, which is suspended and copied to a golden tag. The bottom row happens for each new actor: kubectl ate create actor b200-1 makes an actor in ACTOR_STATE_SUSPENDED that points at the tag snapshot, and the first request restores it to ACTOR_STATE_RUNNING in 0.62 s at 200 MiB, the median of 5 new actors. ONCE FOR EACH TEMPLATE tag listed 21 s after the template ActorTemplate lab-c/ballast-200 golden actor temporary, same atespace golden tag SNAPSHOT_OWNER_TAG 1 2 3 deleted after the copy FOR EACH NEW ACTOR create actor kubectl ate, b200-1 ACTOR_STATE_SUSPENDED gen 1 is the tag snapshot ACTOR_STATE_RUNNING count=1 in the reply 4 5 restore reads it 1 ate-api-server creates a temporary golden actor from the template and resumes it. 2 It suspends that actor at preferredFidelity and copies the snapshot to a published tag. 3 It deletes the golden actor. In our run the tag was listed 21 s after the template. 4 A new actor records the tag snapshot as generation 1 and starts SUSPENDED. 5 The first request restores the tag snapshot on a worker: 0.62 s at 200 MiB.
Substrate builds one golden snapshot for each template, and an actor created after that starts suspended from it. Read the top row once for each template and the bottom row once for each actor. The numbers match the key. Names and seconds are from our resume tests on the x86 server guest.

The first call pays for the restore

The first request to a new actor restores the golden snapshot. For a small actor you do not notice it. For a large one, it is the slowest request that the actor ever serves.

Memory in the templateFirst call of a new actor, medianFastest and slowest of 5Later resume of the same actor, median
0 MiB0.23 s0.22 to 0.24 s0.22 s
50 MiB0.25 s0.24 to 0.40 s0.24 s
200 MiB0.62 s0.56 to 0.86 s0.47 s
500 MiB5.76 s1.86 to 6.76 s1.07 s
1,000 MiB6.60 s3.46 to 12.75 s2.16 s

We measured these times on the x86 server guest (24 vCPU, 47 GB, nested KVM), with gVisor and five new actors for each size. Each time is one request through kubectl ate proxy, timed at the client. A request to an actor that already runs took 0.06 seconds on the same path. The later resume is the median of 10 resumes from the snapshot on the node. kubectl ate create actor returned in 0.20 to 0.26 seconds for all 25 actors. Raw runs

Fig. 3.2the first call of a new actor against its memorychart
The first call of a new actor against the memory in its template Seconds for the first request to a new actor, which restores the golden snapshot, against the MiB of memory the template holds, on the x86 server guest. Each of the 25 new actors is one dot, five for each size, and a line joins the medians: 0.23 s at 0 MiB, 0.25 s at 50 MiB, 0.62 s at 200 MiB, 5.76 s at 500 MiB, 6.60 s at 1000 MiB. The five first calls at 500 MiB took 1.86, 3.03, 5.76, 6.24, 6.76 seconds, and the five at 1000 MiB took 3.46, 3.56, 6.60, 11.85, 12.75 seconds. A second line shows the median of ten later resumes of one actor from its own snapshot on the node, which reaches 2.16 s at 1000 MiB. 0 2 4 6 8 10 12 14 0 50 200 500 1000 memory the template holds, MiB seconds from request to reply, at the client 12.75 s slowest first call 6.60 s median of 5 3.46 s fastest later resume 2.16 s median of 10 five new actors at 500 MiB: 1.86, 3.03, 5.76, 6.24, 6.76 s up to 200 MiB the first call stays under 0.9 s the first call of one new actor median of one size
At 1,000 MiB the first call of a new actor took 3.5 to 12.7 seconds, and a later resume took 2.2 seconds. Each teal dot is the first call of one new actor, five for each size, and the teal line joins the medians. The violet line is the median of ten later resumes of one actor from the node. Measured on the x86 server guest.

Up to 200 MiB the first call costs about the same as a later resume. At 500 MiB and above it costs three to five times more, and the spread is wide. We did not find the cause of that spread. We can say where the node kept data. Its snapshot-cache directory held 1,765 MiB, and the five snapshot sizes of our templates add up to 1,760 MiB.

Our position: the golden snapshot is a good place for slow start code and a bad place for large memory. If your template holds more than about 200 MiB, send one request to each new actor before a user does. Use a request that changes no state: our counter adds one on every call. Then suspend it. A later resume from the node copy follows the lower line.

Actor and its seven states

An actor is always in one of seven states, and that state decides what a request, a resume, and a suspend do.

A request to your actor returns 503. kubectl get pods shows every pod as Running, and the worker pod log does not name your actor. Kubernetes cannot tell you more, because an actor is not a pod. Its state is a row in Postgres, and kubectl ate get actors reads that row.

When you finish this section, you can read the state of an actor and name the call that moves it. You can also predict what a request does in each state.

The Actor object

Actor: one instance of an ActorTemplate, with a name that is unique in its atespace. An actor is the unit that Substrate suspends and resumes. It can run on a different worker after each resume.

This is the counter actor of the quickstart while it runs.

yamlthe actor my-counter-1 in ACTOR_STATE_RUNNINGFull log
actorTemplate:
  atespace: ate-demo-counter
  name: counter
metadata:
  atespace: ate-demo-counter
  createTime: "2026-11-12T10:13:40.987568669Z"
  name: my-counter-1
  uid: 6f80f612-7547-4f5b-b3ab-831595621806
  updateTime: "2026-11-12T10:30:18.103768100Z"
  version: "16"
status:
  assignedNode: kind-control-plane
  lastAssignedGeneration: 3
  snapshots:
  …
  state: ACTOR_STATE_RUNNING
  workerAssignment:
    nodeName: kind-control-plane
    …
    workerPod: counter-7cc7dcd596-fps97
    …
    workerPodUid: c572e96d-dd9b-4500-b0e3-880a98d9f61d
    workerPool: counter
The snapshot list and the runtime attributes are cut.
FieldWhat it tells you
metadata.versionA counter that rises on every change of the actor. This actor reached 16 in 17 minutes.
status.stateOne of the seven states.
status.workerAssignmentThe worker pod that hosts the sandbox. It is set only while a worker holds the actor.
status.assignedNodeThe node of the worker while the actor runs, and the node that keeps the newest snapshot on its disk after a suspend.
status.snapshotsThe snapshots that the actor can resume from, at most 16.
status.crashA time and a message. It is set when the actor enters ACTOR_STATE_CRASHED, and a revert clears it.

workerAssignment and assignedNode answer different questions. The first says where the actor runs now. The second also says where its last snapshot is after a suspend, and section 3.4 shows how long that lasts.

The seven states

The enum ActorState has seven values at v1.0.0. The state diagram on the architecture page of the project draws four of them, so trust the enum.

StateWhat the actor hasWhat a request does
ACTOR_STATE_SUSPENDEDNo sandbox, and zero or more snapshotsThe router resumes the actor, then the actor answers
ACTOR_STATE_RESUMINGA worker, and a restore that is not completeIt waits for the same resume
ACTOR_STATE_RUNNINGA sandbox on a workerThe actor answers
ACTOR_STATE_SUSPENDINGA worker, and a checkpoint that is not completeA request that is open when the suspend starts gets a 503. We did not test a request that arrives in this state
ACTOR_STATE_CRASHEDNo sandbox, and a crash record503 at once, with the state in the text
ACTOR_STATE_REVERTINGA revert that is not complete503 at once, with the state in the text
ACTOR_STATE_DELETINGA delete that is not complete503 at once, with the state in the text

Three of these states are stable. ACTOR_STATE_SUSPENDED lasts until a request or a resume call arrives. ACTOR_STATE_CRASHED lasts until a revert or a delete, and a request does not end it. ACTOR_STATE_RUNNING lasts until a caller suspends the actor or Substrate marks it crashed. Section 3.3 proves that Substrate does not suspend it. The other four states are steps of one operation.

ACTOR_STATE_RUNNING does not prove that the process is alive. Two actors whose process had exited read ACTOR_STATE_RUNNING for the 20 minutes of our watch. Section 3.5 shows that run.

Old tutorials name a state ACTOR_STATE_PAUSED and a command kubectl ate pause actor. Both are gone at v1.0.0.

Fig. 3.3the seven actor statesstate
The seven actor states and the transitions between them A state figure with the seven states of the ActorState enum at v1.0.0. CreateActor makes an actor in ACTOR_STATE_SUSPENDED. ResumeActor moves it through ACTOR_STATE_RESUMING to ACTOR_STATE_RUNNING. SuspendActor moves it through ACTOR_STATE_SUSPENDING and returns it to ACTOR_STATE_SUSPENDED. A failed restore, a lost worker pod, a memory kill or a failed suspend moves it to ACTOR_STATE_CRASHED, where a request gets 503. The only arrow that leaves ACTOR_STATE_CRASHED is RevertActor, which moves the actor through ACTOR_STATE_REVERTING to ACTOR_STATE_SUSPENDED. DeleteActor moves an actor in every state to ACTOR_STATE_DELETING. In the kagent run, on a second x86 server guest with 16 vCPU and 31 GB, an actor spent a median of 0.38 s in ACTOR_STATE_RESUMING over 98 turns and 0.14 s in ACTOR_STATE_SUSPENDING over 99 suspends. A dotted grey arrow is a transition we read in the code and did not run. ACTOR_STATE_SUSPENDED no sandbox, no worker ACTOR_STATE_RESUMING 0.38 s median of 98 ACTOR_STATE_RUNNING a sandbox on a worker ACTOR_STATE_REVERTING later calls are lost ACTOR_STATE_CRASHED a request gets 503 ACTOR_STATE_SUSPENDING 0.14 s median of 99 CreateActor ResumeActor restored SuspendActor the snapshot is on the node restore failed worker pod gone, memory kill suspend failed RevertActor the only way out last durable snapshot ACTOR_STATE_DELETING then the actor is gone DeleteActor, from every state in our traces: amber is a state change, rose is a failure read in the code, not run: RevertActor on a SUSPENDED actor Not drawn, also code only: RevertActor on a RUNNING actor. Times in RESUMING and SUSPENDING: the kagent run, files-only actors. A process that exits leaves the actor RUNNING. Section 3.5 has that case.
An actor leaves ACTOR_STATE_CRASHED only through RevertActor or DeleteActor, and no request can resume it. Read the top row left to right for a resume, and follow the right edge down for a suspend. The thick arrow is the only one that leaves the rose state. Amber and rose arrows are transitions from our traces, and the dotted grey arrow comes from the code only. The two times are medians from the kagent guest.

The transitions we saw

We drove my-counter-1 through a suspend, a resume, a crash, and a revert in 13 seconds. A script read the state after each step.

outputone actor from suspended to crashed and backFull log
2026-11-12T10:30:09.378Z	state	ACTOR_STATE_SUSPENDED	kind-control-plane	9	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
2026-11-12T10:30:10.310Z	state	ACTOR_STATE_RUNNING	kind-control-plane	11	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
…
2026-11-12T10:30:11.590Z	state-after-crash	ACTOR_STATE_CRASHED	kind-control-plane	12	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
…
2026-11-12T10:30:17.724Z	state	ACTOR_STATE_SUSPENDED	-	14	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
…
2026-11-12T10:30:18.333Z	state	ACTOR_STATE_RUNNING	kind-control-plane	16	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
Five lines of the trace. The columns are the time, the event, the state, the assigned node, the version, and the snapshots.

Look at the version column. A resume moved it from 9 to 11, and the revert moved it from 12 to 14. Each operation writes the actor two times: one write for the step state, and one for the stable state. Our script read the state after each command returned, so it never saw the state between.

The log of ate-api-server does show those states. The listing in section 3.1 has resuming before running, suspending before suspended, and deleting before deleted.

We measured the step states in the kagent run, on a second x86 server guest (16 vCPU, 31 GB). There a harness resumes and suspends an actor for every turn. Over 98 turns, the actor spent a median of 0.38 seconds in ACTOR_STATE_RESUMING. Over 99 suspends, it spent a median of 0.14 seconds in ACTOR_STATE_SUSPENDING. Those actors keep files only, so the times are low. Raw runs

A step state can also last. In our dependency test, one actor was still in ACTOR_STATE_RESUMING five minutes after Postgres came back. Section 8.2 has that case.

Who moves an actor

CallAccepted fromResultWho calls it
CreateActorno actorSUSPENDEDYou, or your harness
ResumeActorSUSPENDED, RESUMING (the call continues the resume), and RUNNING (no change)RESUMING, then RUNNINGThe router for every request, or kubectl ate resume actor
SuspendActorRUNNINGSUSPENDING, then SUSPENDEDYou, or your harness
RevertActorCRASHED, RUNNING, SUSPENDED with a snapshot on the node, and REVERTING (the call continues the revert)REVERTING, then SUSPENDEDYou, or your harness
DeleteActorevery stateDELETING, then no actorYou, or your harness

Substrate itself makes one move: it marks an actor ACTOR_STATE_CRASHED when the sandbox, the worker, or a restore is lost. Section 3.5 lists every way that we reached that state.

A call in the wrong state does not wait. ResumeActor on a crashed actor returns FailedPrecondition at once, and a request gets the same text as a 503. SuspendActor on a suspended actor returns the actor with no change.

The router calls ResumeActor for every request, also when the actor runs. On a running actor the call succeeds, changes nothing, and returns resumed=false. That is why a plain HTTP request is enough to start a suspended actor. Section 1.2 follows one such request.

Our position: read the state before you debug the network. kubectl ate get actors -a <atespace> costs one call. It separates a crashed actor from a full pool and from a dead process, and each of those needs a different fix.

SuspendActor and ResumeActor

Substrate suspends an actor only when a caller asks, and on our test server a resume cost about 2 ms for each MiB that holds data.

You created an actor in the afternoon, sent it five requests, and went home. The quickstart told you that Substrate checkpoints an idle actor. The next morning the actor is still ACTOR_STATE_RUNNING, and it still holds a full CPU of its worker. Nothing is broken. Substrate v1.0.0 has no idle timer.

When you finish this section, you can decide who calls SuspendActor in your system and choose between its two modes. You can also predict the resume time of an actor from the memory that it holds.

Substrate does not suspend an idle actor

We sent five requests to my-counter-1 and then sent nothing for 900 seconds. A script read the state every 15 seconds.

output900 seconds with no requestFull log
2026-11-12T10:14:22.822Z	idle-watch-start	no requests for 900 s
2026-11-12T10:14:37.832Z	state	ACTOR_STATE_RUNNING	-
…
2026-11-12T10:29:35.957Z	state	ACTOR_STATE_RUNNING	-
2026-11-12T10:29:36.168Z	idle-watch-end
2026-11-12T10:29:36.265Z	call-after-idle	200	0.080377	preserved memory count: 6 | preserved file counter: 6 | preserved rootfs counter: 6
The start and the end of the watch. The 58 state lines between them are cut, and all of them say ACTOR_STATE_RUNNING.

All 60 samples were ACTOR_STATE_RUNNING, and the sixth call continued the count in 0.08 seconds. The code agrees: no component of v1.0.0 suspends an actor because it is idle. The API reference says the same, and the quickstart page does not.

An idle running actor is not free. It keeps the CPU and the memory that its template booked on the worker. While my-counter-1 ran, its worker showed 1,000 of 1,000 millicores in use. Section 4.5 turns that into a pool size.

So the job belongs to the caller, and in practice to the agent harness. In our run, kagent suspended the actor after 98 of 98 turns. Section 7.1 shows that pattern and its cost.

SuspendActor: asynchronous or synchronous

SuspendActor: the call that checkpoints a running actor, stops its sandbox, and frees its place on the worker. It has two durability modes.

  • The asynchronous mode is the default. The call returns when the snapshot is on the disk of the node. atelet uploads it to the object store later.
  • The synchronous mode returns only when the snapshot is in the object store. Use kubectl ate suspend actor --mode sync.

The actor object shows the difference.

outputthe snapshots of one actor after each kind of suspendFull log
2026-11-12T10:30:08.028Z	state	ACTOR_STATE_SUSPENDED	kind-control-plane	5	SNAPSHOT_OWNER_TAG:SNAPSHOT_STORAGE_STATE_COMPLETED:gen1,SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen2
…
2026-11-12T10:30:09.378Z	state	ACTOR_STATE_SUSPENDED	kind-control-plane	9	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
Two lines of the trace. The first follows an asynchronous suspend, and the second follows a suspend with --mode sync.

After the asynchronous suspend, generation 2 is SNAPSHOT_STORAGE_STATE_PENDING. The object store does not have it yet, so the older generation stays as the durable copy. After the synchronous suspend, generation 3 is SNAPSHOT_STORAGE_STATE_COMPLETED, and Substrate has released the older ones. Section 3.4 follows such a snapshot for 42 minutes.

Memory heldAsynchronous suspend, medianSynchronous suspend, median
0 MiB0.34 s0.40 s
50 MiB0.50 s1.03 s
200 MiB0.79 s2.02 s
500 MiB1.17 s3.59 s
1,000 MiB1.95 s6.41 s

We measured the time until kubectl ate suspend actor returned, on the x86 server guest (24 vCPU, 47 GB, nested KVM) with gVisor. The asynchronous column has 10 suspends for each size, and the synchronous column has 5. The actor held random bytes, which do not compress. The extra time is close to the upload itself. In a separate run, atelet logged 2.0 to 2.1 seconds for each of four uploads of a 500 MiB snapshot. Raw runs

Fig. 3.4asynchronous against synchronous suspendchart
What a synchronous suspend costs the caller Seconds for kubectl ate suspend actor to return against the MiB of random memory the actor holds, on the x86 server guest. Every one of the 75 suspends is a small dot, and a line joins the medians. The default asynchronous suspend returns when the snapshot is on the node, and its median is 0.34 s at 0 MiB, 0.50 s at 50 MiB, 0.79 s at 200 MiB, 1.17 s at 500 MiB, 1.95 s at 1000 MiB, with ten suspends for each size. With --mode sync the command returns after the upload to the object store, and its median is 0.40 s at 0 MiB, 1.03 s at 50 MiB, 2.02 s at 200 MiB, 3.59 s at 500 MiB, 6.41 s at 1000 MiB, with five suspends for each size. At 1000 MiB the synchronous suspend takes 4.5 s more. 0 2 4 6 8 0 50 200 500 1000 memory the actor holds, MiB seconds until kubectl ate suspend actor returns --mode sync 6.41 s median of 5 after the upload async, the default 1.95 s median of 10 snapshot on the node 4.5 s more to wait for the upload one suspend median of one size
At 1,000 MiB a synchronous suspend took 6.4 seconds, and the default asynchronous suspend took 2.0 seconds. Each dot is one suspend, and each line joins the medians of one mode. Violet returns with the snapshot on the node, and teal returns after the upload. Measured on the x86 server guest.

A suspend does not wait for open requests

A suspend stops the sandbox at once, also when the actor is in the middle of a request. We gave an actor a handler that sleeps 10 seconds, sent it one request, and suspended the actor 2 seconds later.

outputa suspend 2 seconds into a 10 second requestFull log
2026-11-12T12:52:10.848Z	async	slow-request-ended	503	2.218030	upstream connect error or disconnect/reset before headers. reset reason: connection termination
2026-11-12T12:52:10.992Z	async	suspend-2s-into-request	0.376s	ATESPACE   NAME       TEMPLATE       STATE                   WORKER POD   WORKER IPS   VERSION   AGE lab-v      sl-async   lab-v/tiny-0   ACTOR_STATE_SUSPENDED…
…
2026-11-12T12:52:11.431Z	async	next-call	200	0.228121	tag=v3 count=2 mb=0 fill=zero sum=0 rootfs=-1 data=-1 pid=1 process_age_s=2.4 child[none]
Three lines of the asynchronous run. The end of the suspend line is cut, and the lines between them are cut.

We measured this once in each mode on the x86 server guest (24 vCPU, 47 GB, nested KVM). The client of the open request got a 503 after 2.2 seconds in both modes. The suspend call returned success in 0.38 seconds in the asynchronous mode and 0.48 seconds in the synchronous mode, with no warning. The next request resumed the actor in 0.23 seconds, and the count continued at 2. Raw runs

Substrate does not replay the request that was cut. The snapshot holds the actor in the middle of its handler, and the client is gone when the handler ends. So the caller of SuspendActor must know that the actor is idle. A harness that suspends after each turn knows it. A timer that suspends by clock does not.

ResumeActor: who calls it and where it runs

ResumeActor: the call that places a suspended actor on a worker and restores its newest snapshot. The router makes this call for every request, so a plain request is enough to start an actor. kubectl ate resume actor makes the same call with no request.

A resume looks at the node first. After a suspend, the actor stays assigned to its node for as long as that node holds the snapshot. ResumeActor then tries the workers on that node. If the snapshot is only in the object store, atelet downloads it first, on the node of the worker that the scheduler chose.

Resume time against the memory held

We built an actor that holds a fixed amount of random memory and reads one byte in each MiB on every request. We suspended it, waited, and timed the next request.

Fig. 3.5resume time against memory held, from the node and from the object storechart
Resume time against the memory an actor holds Seconds for one request to a suspended actor against the MiB of random memory it holds, on the x86 server guest with gVisor. Every one of the 75 resumes is a small dot, and a line joins the medians. From the snapshot on the node the median is 0.22 s at 0 MiB, 0.24 s at 50 MiB, 0.47 s at 200 MiB, 1.07 s at 500 MiB, 2.16 s at 1000 MiB, with ten resumes for each size. One resume at 1000 MiB took 5.09 s and the other nine took at most 2.45 s. From the object store in the same cluster the median is 0.24 s at 0 MiB, 0.41 s at 50 MiB, 0.96 s at 200 MiB, 2.26 s at 500 MiB, 4.68 s at 1000 MiB, with five resumes for each size. A dashed line marks 0.5 s, which the node path passes above 200 MiB and the object store path above 50 MiB. 0 1 2 3 4 5 6 0 50 200 500 1000 memory the actor holds, MiB seconds from request to reply, at the client object store 4.68 s median of 5 node 2.16 s median of 10 5.09 s, the first of 10 from the node about 4 ms per MiB about 2 ms per MiB 0.5 s what the project states one resume median of one size under 0.5 s up to 200 MiB from the node and up to 50 MiB from the object store
A resume from the node took 2.2 seconds at 1,000 MiB, and a resume from the object store took 4.7 seconds. Each dot is one resume, and each line joins the medians of one path. Violet restores the snapshot on the node, and teal downloads it from the object store first. The dashed line marks 0.5 seconds. The first resume from the node at 1,000 MiB took 5.09 seconds, and the other nine took at most 2.45 seconds. Measured on the x86 server guest.
Memory heldFrom the node, medianRange of 10From the object store, medianRange of 5
0 MiB0.22 s0.22 to 0.23 s0.24 s0.24 to 0.25 s
50 MiB0.24 s0.23 to 0.26 s0.41 s0.37 to 0.47 s
200 MiB0.47 s0.35 to 0.54 s0.96 s0.90 to 1.01 s
500 MiB1.07 s1.00 to 1.34 s2.26 s1.93 to 2.41 s
1,000 MiB2.16 s1.94 to 5.09 s4.68 s4.23 to 4.97 s

We measured one request to a suspended actor on the x86 server guest (24 vCPU, 47 GB, nested KVM), with gVisor and one kind node. The time is at the client, through kubectl ate proxy. A request to a running actor took 0.06 seconds on that path. For the object store column, atelet ran with an upload delay of 3 seconds and a detach delay of 10 seconds. All 55 requests of that run returned 200: 5 first calls, 25 resumes, and 25 calls to a running actor. Raw runs

The atelet log says where the second column loses its time.

outputone resume of a 500 MiB actor from the object store, as atelet timed itFull log
{"time":"2026-11-12T11:37:01.905071369Z","level":"INFO","msg":"Restore timing breakdown",…"ate.actor.name":"o500",…"ate.snapshot.fidelity":"memory",…"ate.actor.restore.duration.download":1.171619862,…"ate.actor.restore.duration.worker_restore":0.326837467,"ate.actor.restore.duration.total":1.5049079490000001,…
One log line. Eight fields are kept and the others are cut.

The download took 1.17 seconds of the 1.50 seconds that atelet counted. The worker needed 0.33 seconds for the restore itself.

Our rule of thumb for this machine has two numbers. A resume costs a fixed 0.2 seconds, plus about 2 ms for each MiB from the node. From the object store, it costs about twice as much for each MiB. The rule has four conditions, and you must check each one against your case.

  • The memory was random, so every MiB held data. The rule counts the memory that holds data, as the next subsection shows.
  • The object store ran in the same cluster and on the same disk. On another guest, 20 ms of added round trip to the bucket took a resume of 1,000 MiB from 4.8 s to 10.2 s. Section 8.1 has that run.
  • The sandbox was gVisor. On the microVM class the same test cost a fixed 0.3 seconds plus about 1 ms for each MiB, up to 500 MiB. Section 4.2 has the comparison.
  • The machine was a nested guest on a server CPU from 2019.

The project states that a resume takes less than 500 ms, and it gives no actor size and no machine. On our guest, that holds up to about 200 MiB from the node and up to about 50 MiB from the object store.

The rule counts memory that holds data

Random bytes are the worst case for a snapshot. We repeated the node path with three kinds of memory at 200 and 500 MiB.

Memory heldAll zero bytesHalf zero, half randomAll random bytes
200 MiB0.24 s0.28 s0.42 s
500 MiB0.23 s0.57 s1.07 s

We measured 10 resumes from the node for each cell, on the x86 server guest (24 vCPU, 47 GB, nested KVM). Each cell is the median, and the sandbox was gVisor. All 60 resumes returned 200, and every count continued. The random column repeats the earlier table within 12 percent. Raw runs

An actor with 500 MiB of zero pages resumed as fast as an empty actor. Half the data cost about half the extra time. So put the MiB that hold data into the rule, and not the limit of the template. Section 3.4 shows that the snapshot size follows the same bytes.

An ordinary runtime: Python with 245 MiB

Ballast is not an application. We checked the rule with a Python server that builds a dict of one million random 100-byte values at start. It keeps a counter in memory and a SQLite file on a durableDir volume.

pythonthe Python actor, from its start to its first tableFull log
import os, resource, sqlite3, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

started = time.time()
state = {"count": 0, "ready": False}
data = {i: os.urandom(100) for i in range(1_000_000)}
os.makedirs("/data", exist_ok=True)
db = sqlite3.connect("/data/app.db", check_same_thread=False)
db.execute("create table if not exists hits (n integer)")
db.commit()
state["ready"] = True
…
The first eleven lines of the file. The HTTP handler below them is cut.
StepResult
Resume from the nodemedian 0.41 s, 0.37 to 0.54 s, n=10
First call of a new actormedian 0.63 s, 0.62 to 0.99 s, n=5
Asynchronous suspendmedian 0.81 s, n=10
Synchronous suspendmedian 1.93 s, n=3
Snapshot on the node215.2 MiB

We measured one actor of the image python:3.13-slim on the x86 server guest (24 vCPU, 47 GB, nested KVM). The sandbox was gVisor, the limit was 768 MiB, and the fidelity was memory. The process reported a peak resident size of 245 MiB. All 29 requests returned 200. The memory counter, the SQLite row count, and the dict continued across 13 suspends, and the process ID stayed 1. Raw runs

The rule holds. It gives 0.2 seconds plus 215 MiB at 2 ms, which is 0.63 seconds, and the measured median was 0.41 seconds. An interpreter heap is not all random data, so the rule is an upper estimate for it.

One detail matters for code that reads a clock. The process reported an age of 32.9 seconds at its first request and 65.1 seconds at its last, 13 suspends later. Under gVisor the clock of the actor does not count the time that it was suspended. Section 4.2 compares the two classes on this point.

The same test on Apple silicon

We measured the node path on an Apple silicon laptop under Docker Desktop, with 10 CPU and 8 GB for Docker. Each size had 10 resumes. The median resume was 0.09 seconds at 0 MiB, 0.09 seconds at 50 MiB, and 0.16 seconds at 200 MiB. The median asynchronous suspend was 0.10, 0.15, and 0.24 seconds. All 66 requests returned 200, and every count continued. We did not test more than 200 MiB or the object store path on the laptop. Raw runs

The laptop and the server guest are different machines. Read each table by itself, and do not take a ratio across them.

Our position

Call SuspendActor yourself, from the component that knows when a turn ends. Use the asynchronous mode for the normal case, because the caller waits a third as long at 1,000 MiB. Use the synchronous mode for any state that you cannot lose, because it makes the snapshot durable before the call returns.

A durable snapshot does not move the actor. In our two-node run a sync-suspended actor resumed on its own node while that node was cordoned. When the node stopped, the actor failed its requests and became ACTOR_STATE_CRASHED. A drain is the exception: three suspended actors resumed on the other node at their next request. Section 4.3 has the runs.

Suspend only an actor that has no open request. Budget the resume from the memory that holds data in your actor, and not from a headline number.

Snapshot fidelity, and where a snapshot lives

A snapshot keeps what its fidelity names, it is as large as the memory that holds data, and for five minutes it exists on one node only.

Your agent wrote a file to /tmp, started a background process, and ended its turn. On the next turn the file is gone and the process does not exist. Nothing failed. The template asked for a snapshot of files only, and /tmp is not one of those files. A second surprise waits on the node: the snapshot that you took a minute ago is not in the object store yet.

When you finish this section, you can choose a fidelity for a template and estimate the disk that suspended actors need. You can also say where a snapshot is at each minute after a suspend.

Three fidelities

Snapshot fidelity: the set of state layers that a snapshot holds. A template names the highest level that it wants in snapshotConfig.preferredFidelity, and each level includes the levels below it.

FidelityThe snapshot holdsA resume
SNAPSHOT_FIDELITY_VOLUMESThe durableDir volumesBoots the containers from the image, with the volumes back
SNAPSHOT_FIDELITY_ROOTFSThe volumes, and the writes to the root filesystem since bootBoots the containers from the image, with those writes back
SNAPSHOT_FIDELITY_MEMORYThe volumes, the root filesystem writes, and the process memoryContinues the same processes

kagent uses the first level. Its template mounts one durableDir volume at /data and sets SNAPSHOT_FIDELITY_VOLUMES. In the first turn of a session, we told the agent to write to three places and to start a process.

outputwhat turn 1 wroteFull log
RUN: mkdir -p /data/workspace && echo hello-from-turn1 > /data/workspace/note.txt; echo scratch-turn1 > /tmp/scratch.txt; echo rootfs-turn1 > /root-marker.txt 2>/dev/null; (nohup sleep 100000 >/dev/null 2>&1 &) ;…
The first line of the file. The end of the command, which only prints values, is cut.

kagent suspended the actor after that turn. The second turn read the same places.

outputwhat turn 2 foundFull log
note=hello-from-turn1
scratch=cat: can't open '/tmp/scratch.txt': No such file or directory
bg=
uptime=0.74
The first four lines of the file.

The note under /data is there. The file in /tmp is gone, the sleep process is gone, and the sandbox is 0.74 seconds old. The write to / failed in turn 1, because the harness does not run as root.

The other end is SNAPSHOT_FIDELITY_MEMORY, which our counter and our memory test used. To compare all three levels, we ran one actor under each level. The actor holds 50 MiB of random bytes and starts a child process. It keeps one count in memory, one in a file on the root filesystem, and one in a file on a durableDir volume.

FidelityMemory countRoot filesystem countVolume countProcesses after a resumeSnapshot on the nodeResume, median of 10
SNAPSHOT_FIDELITY_VOLUMESback to 1back to 1continued to 25new, 0.2 to 0.4 s old44 KiB0.52 s
SNAPSHOT_FIDELITY_ROOTFSback to 1continued to 25continued to 25new, 0.2 to 0.3 s old72 KiB0.50 s
SNAPSHOT_FIDELITY_MEMORYcontinued to 25continued to 25continued to 25the same, with the same child56.9 MiB0.25 s

We measured these three actors on the x86 server guest (24 vCPU, 47 GB, nested KVM), with gVisor and one actor for each level. Each actor got 25 requests with 13 suspends between them, and all 75 requests returned 200. The resume column is one request to the suspended actor, timed at the client, with the snapshot on the node. An asynchronous suspend took a median of 0.32 seconds at the two lower levels and 0.54 seconds at the memory level, with 10 suspends each. Raw runs

A resume at the two lower levels is a cold start of the container. The snapshot is a few KiB, so the cost is the start time of your application. Our Go actor starts in 0.3 seconds, and its resume took 0.5 seconds, twice the resume at the memory level. The kagent run was on a second x86 server guest (16 vCPU, 31 GB). There the harness image resumed in a median of 0.38 seconds under SNAPSHOT_FIDELITY_VOLUMES.

A Python agent is slower to start. We ran one on the same guest, on the cluster of the Part 9 demos, with three turns at each level. A turn after a suspend took 4.5 to 5.1 seconds under SNAPSHOT_FIDELITY_VOLUMES. It took 0.30 to 0.32 seconds under SNAPSHOT_FIDELITY_MEMORY. A stateful agent actor shows that run.

Fig. 3.6what each fidelity keepscomparison
What each snapshot fidelity keeps Three columns for the three snapshot fidelity levels. SNAPSHOT_FIDELITY_VOLUMES keeps only durable volumes and resumes with new processes. SNAPSHOT_FIDELITY_ROOTFS keeps durable volumes and root filesystem writes and also resumes with new processes. SNAPSHOT_FIDELITY_MEMORY keeps volumes, root filesystem writes and process memory, so the same processes continue. Rows under the columns give what one actor for each level showed on the x86 server guest after 25 calls with suspends between them: its three counts, its median resume from the node, and its snapshot size. VOLUMES: counts memory 1, rootfs 1, volume 25, 0.52 s median of 10, snapshot 44 KiB. ROOTFS: counts memory 1, rootfs 25, volume 25, 0.50 s median of 10, snapshot 72 KiB. MEMORY: counts memory 25, rootfs 25, volume 25, 0.25 s median of 10, snapshot 56.9 MiB. LAYER VOLUMES ROOTFS MEMORY durable volumes durableDir mounts kept kept kept root filesystem writes since boot gone kept kept process memory processes and RAM gone gone kept ON RESUME COUNTS after 25 calls new processes 0.52 s median of 10 snapshot 44 KiB memory 1, rootfs 1, volume 25 new processes 0.50 s median of 10 snapshot 72 KiB memory 1, rootfs 25, volume 25 same processes 0.25 s median of 10 snapshot 56.9 MiB memory 25, rootfs 25, volume 25 The enum names carry the prefix SNAPSHOT_FIDELITY_. Each level includes the levels to its left. A count of 1 means that the count started again at the last resume.
Only SNAPSHOT_FIDELITY_MEMORY keeps the processes, and the two lower levels start new processes on every resume. Read each column top to bottom. Teal layers are in the snapshot, and grey layers are lost at the suspend. The last rows give the median resume and the three counts of one actor for each level, measured on the x86 server guest.

preferredFidelity is an upper limit, and a memory snapshot can restore as less. A memory snapshot records the CPU architecture, the CPU features, and the sandbox build of the worker that took it. It restores only on a worker that matches. If no such worker has room, Substrate restores the files on another worker and boots the processes new. We did not see that downgrade in any run. Write your actor so that it survives a boot from its files, also at the memory level.

A snapshot is as large as the memory that holds data

Start with the worst case, an actor whose memory is random bytes.

Memory the actor holdsSnapshot on the node
0 MiB1.3 MiB
50 MiB51.7 MiB
200 MiB201.9 MiB
500 MiB502.2 MiB
1,000 MiB1,002.7 MiB

One file holds almost all of it.

outputthe largest file in the snapshot of a 200 MiB actorFull log
211574784 /var/lib/ate/snapshots/a77c2723-c524-46d0-9a7e-caa97f36ab9c/local-checkpoint/…/pages.img
The last line of the file. The first number is the size in bytes, and the name of the checkpoint folder is cut.

We measured these sizes with du on the kind node of the x86 server guest, one actor for each size. The memory was random bytes. The snapshot folder of an actor is named by the uid of the actor. Raw runs

Random bytes are the largest snapshot that an actor of that size can have. We ran the same actor again with zero bytes, and with half zero and half random bytes.

Fig. 3.7snapshot size on the node for three kinds of memorychart
Snapshot size on the node against memory held, for three kinds of memory MiB of the snapshot on the node against the MiB of memory that the actor holds, on the x86 server guest with gVisor, one actor for each of six templates. All random bytes: 203.8 MiB at 200 MiB held and 504.1 MiB at 500 MiB held. Half zero and half random: 103.8 MiB at 200 MiB held and 254.4 MiB at 500 MiB held. All zero bytes: 4.0 MiB at 200 MiB held and 4.6 MiB at 500 MiB held. A dashed line marks a snapshot as large as the memory held. The median resume from the node, of ten for each actor, is 0.42 s and 1.07 s for random, 0.28 s and 0.57 s for half and half, and 0.24 s and 0.23 s for zero. 0 100 200 300 400 500 0 200 500 memory the actor holds, MiB snapshot on the node, MiB size equals memory held all random bytes 203.8 and 504.1 MiB the size of the memory resume 0.42 and 1.07 s half zero, half random 103.8 and 254.4 MiB half of the memory resume 0.28 and 0.57 s all zero bytes 4.0 and 4.6 MiB about 4 MiB at both sizes resume 0.24 and 0.23 s one actor, one du reading resume: median of 10, from the node
An actor that held 500 MiB of zero bytes had a 4.6 MiB snapshot, and the same actor with random bytes had 504.1 MiB. Each dot is the snapshot of one actor on the node, and each line joins the two sizes of one kind of memory. The dashed line marks a snapshot as large as the memory held. The violet text is the median of ten resumes from the node. Measured on the x86 server guest.
Memory the actor holdsSnapshot on the nodeCopy in the object storeResume from the node, medianAsynchronous suspend, median
200 MiB of zero bytes4.0 MiB1.1 MiB0.24 s0.35 s
500 MiB of zero bytes4.6 MiB1.1 MiB0.23 s0.35 s
200 MiB, half zero and half random103.8 MiB101.1 MiB0.28 s0.66 s
500 MiB, half zero and half random254.4 MiB251.2 MiB0.57 s0.79 s
200 MiB of random bytes203.8 MiB201.2 MiB0.42 s0.80 s
500 MiB of random bytes504.1 MiB501.3 MiB1.07 s1.07 s

We measured six actors on the x86 server guest (24 vCPU, 47 GB, nested KVM), one for each row. Each actor had 10 resumes and 10 asynchronous suspends. All 60 resumes returned 200, and every count continued. Each size is one du reading, on the node and in the data folder of the object store. Raw runs

So the size follows the memory that holds data, and the resume time follows the same bytes. An actor with 500 MiB of zero pages resumed in 0.23 seconds, as fast as an actor that holds nothing. Do not count on that for a real heap. A Python process with a dict of one million random values reported a peak of 245 MiB. Its snapshot on the node was 215.2 MiB. Section 3.3 has its resume time.

The object store did not make the random snapshots smaller. Substrate compresses each object before the upload, and random bytes do not compress. The copy of the Python actor was 110.1 MiB in the object store, about half of its node copy. We did not find out which step made it smaller.

WhereWith 25 suspended actorsAfter we deleted them
Snapshots on the node8,801 MiB1 MiB
Snapshot cache on the node1,765 MiB1,765 MiB
Object store10,824 MiB2,064 MiB

We measured this footprint on the x86 server guest (24 vCPU, 47 GB, nested KVM). The node had five actors for each of the five sizes, which hold 8,750 MiB together. Each actor was 3 to 8 minutes old. The 2,064 MiB that stayed in the object store belong to golden snapshots. A template keeps its golden snapshot until you delete the template. Raw runs

Fig. 3.8disk use of 25 suspended actors, and after their deletechart
Disk use of 25 suspended actors, and what a delete gives back Horizontal bars with the MiB that du reports in three places on the x86 server guest, with 25 suspended actors that hold 8,750 MiB of memory together and again after we deleted the actors. Snapshots on the node: 8,801 MiB, then 1 MiB. Snapshot cache on the node: 1,765 MiB, then 1,765 MiB. Object store: 10,824 MiB, then 2,064 MiB, which are the golden snapshots of the five templates. A dashed line marks 8,750 MiB. with 25 suspended actors after we deleted the actors 8,750 MiB: the memory the 25 actors hold snapshots on the node gone with the actors 8,801 1 snapshot cache on the node did not shrink 1,765 1,765 object store golden snapshots stay 10,824 2,064 0 2,000 4,000 6,000 8,000 10,000 12,000 MiB Five actors for each of 0, 50, 200, 500, and 1,000 MiB of random bytes, each 3 to 8 minutes old. The object store holds one snapshot for each actor and one golden snapshot for each template.
25 suspended actors that held 8,750 MiB of memory used 8,801 MiB on the node and 10,824 MiB in the object store. Each pair of bars is one place. The full bar is the size with the 25 suspended actors, and the outlined bar is the size after we deleted them. The dashed line marks the memory that the actors hold together. Measured on the x86 server guest.

An actor owns one snapshot in the object store, or two after an asynchronous suspend. A synchronous suspend deletes the earlier snapshot. An asynchronous suspend keeps it, because the new snapshot is not yet durable. The first suspend after the new snapshot is durable deletes the old one, and a delete of the actor removes both.

So plan the object store for up to two times the memory that holds data in all suspended actors. Add one golden snapshot for each template. Plan the node disk for the actors that were suspended in the last 30 minutes. Add the snapshot cache, which still held 1,765 MiB here after we deleted all 25 actors.

Two timers decide where the snapshot is

Node-local snapshot: the copy that the worker writes to the disk of its node at a suspend. atelet owns it.

Durable snapshot: the copy in the object store. Its state is SNAPSHOT_STORAGE_STATE_PENDING until the upload is complete, and then SNAPSHOT_STORAGE_STATE_COMPLETED.

After an asynchronous suspend, atelet runs two timers for each snapshot.

  • --snapshot-upload-delay, 5 minutes by default: the wait before the upload to the object store.
  • --snapshot-detach-delay, 30 minutes by default: the time until atelet removes the copy on the node and clears the node assignment of the actor.

We suspended five actors and read their state every 10 seconds for 42 minutes. The fifth actor, at 1,000 MiB, never got a worker, because the pool had no room. It made no snapshot of its own, so the numbers below are for four actors.

outputthe upload, five minutes after the suspendFull log
2026-11-12T10:31:10.270Z	d0	suspended-async	ACTOR_STATE_SUSPENDED	kind-control-plane	…SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen2:kind-control-plane
…
2026-11-12T10:36:01.379Z	d0	state	ACTOR_STATE_SUSPENDED	kind-control-plane	…SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen2:kind-control-plane
2026-11-12T10:36:12.367Z	d0	state	ACTOR_STATE_SUSPENDED	kind-control-plane	…SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:kind-control-plane
Three lines for the actor d0. The first snapshot in each line, the golden one, is cut.
outputthe detach, thirty minutes after the suspendFull log
2026-11-12T11:01:02.649Z	d0	state	ACTOR_STATE_SUSPENDED	kind-control-plane	…SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:kind-control-plane
2026-11-12T11:01:13.670Z	d0	state	ACTOR_STATE_SUSPENDED	-	…SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:-
Two lines for the actor d0. The node name becomes a dash in the fifth column and at the end of the snapshot.

We measured four actors on the x86 server guest (24 vCPU, 47 GB, nested KVM), with the default timers. They held 0, 50, 200, and 500 MiB. The upload was complete 301 to 311 seconds after the suspend. The node copy was gone 1,801 to 1,803 seconds after the suspend. At that moment the snapshots on the node shrank from 758 MiB to 1 MiB. Each time can be up to 10 seconds late, because that was our sample interval. Raw runs

Fig. 3.9where a snapshot lives after an asynchronous suspendtimeline
Where a snapshot lives after an asynchronous suspend A timeline of four actors, d0, d50, d200 and d500, over 43 minutes after an asynchronous suspend with default atelet timers on the x86 server guest. For the first 5 minutes each snapshot is only on the node and its state is PENDING. From about 5 minutes to about 30 minutes the snapshot is on the node and in the object store. After about 30 minutes the node copy is removed and only the object store copy remains. The first three uploads completed after 5 min 1 s to 5 min 2 s, and the 500 MiB one after 5 min 11 s. A request 42 minutes after the suspend resumed each actor from the object store in 0.25 s, 0.52 s, 1.19 s, 2.44 s. A row of dots under the bars shows the size of the snapshots on the node disk in each of 38 samples: 758 MiB until the node copies were removed and 1 MiB after that. node only, PENDING node and object store object store only d0 0 resumed in 0.25 s d50 50 resumed in 0.52 s d200 200 resumed in 1.19 s d500 500 resumed in 2.44 s MiB upload done after 5 min 1 s node copy removed after 30 min 3 s node disk 758 MiB of snapshots, one dot for each sample 1 MiB 0 5 10 15 20 25 30 35 40 MINUTES one request 42 minutes after the suspend: every actor resumed from the object store In the rose part, losing the node copy leaves the actor CRASHED. Each time is at most 10 s late.
For the first five minutes after an asynchronous suspend, the only copy of the new snapshot is on one node. Each row is one actor, with its memory in MiB. Rose is the time with one copy, violet is the time with two, and teal is the time with the object store copy only. The amber dot is the resume that ended our watch, with its time at the client. The dots under the rows are the size of the snapshots on the node disk.

The three periods mean three different things for you.

  1. In the first 5 minutes, the node holds the only copy. If that copy is lost, the actor is ACTOR_STATE_CRASHED, and the work since the last durable snapshot is gone. Section 3.5 shows that loss.
  2. From 5 to 30 minutes, both copies exist. A resume uses the node and takes the lower line of section 3.3.
  3. After 30 minutes, only the object store has the snapshot. A resume downloads it first and takes about twice as long for each MiB. That number is for a bucket in the cluster, and section 8.1 shows a bucket that is 20 ms away.

The documentation promises neither the start nor the end of the upload. Treat 5 minutes as the shortest exposure, not as a limit.

Our position: do not accept the first period for state that matters. A synchronous suspend closes it for one actor. A shorter --snapshot-upload-delay on atelet makes it shorter for the cluster, and does not close it. We ran atelet with 3 seconds and 10 seconds for one test, and all 55 requests of that test succeeded. A short detach delay saves node disk and makes every resume a download.

A tag is a copy that does not depend on the node

Tag: a named copy of the durable snapshot of a suspended actor. kubectl ate create tag <name> --actor <actor> makes one. If the snapshot is not uploaded yet, the call uploads it first. kubectl ate create actor <new> --tag <name> creates a new actor that starts from the copy.

A tag has its own object in the object store, under the atespace and outside the folder of the actor. So it does not need the node of the actor, and it does not change when the actor runs on. That makes a tag the one way to move the state of an actor off a node that has stopped.

outputthe tags of one atespaceFull log
ATESPACE   NAME                                   SCOPE                 STATE   SNAPSHOT                                                                       FIDELITY                   AGE
lab-n      09fee120-cd19-4121-8bae-5623fcbe70eb   TAG_SCOPE_PUBLISHED   Ready   gs://ate-snapshots/atespaces/lab-n/tags/1010ca2e-25b0-44d6-864f-a31a80da48c2   SNAPSHOT_FIDELITY_MEMORY   147m
lab-n      d8328610-2fdb-4974-a6ab-7c8671586d37   TAG_SCOPE_PUBLISHED   Ready   gs://ate-snapshots/atespaces/lab-n/tags/8d60620d-653e-469f-9dd1-9f11a89ce7e0   SNAPSHOT_FIDELITY_MEMORY   147m
lab-n      keep-t1                                TAG_SCOPE_ATESPACE    Ready   gs://ate-snapshots/atespaces/lab-n/tags/aca8c8e0-6dfd-4ee5-9e55-ced17f7c346e   SNAPSHOT_FIDELITY_MEMORY   12m
lab-n      keep-u1                                TAG_SCOPE_ATESPACE    Ready   gs://ate-snapshots/atespaces/lab-n/tags/13e4dabd-ad64-417f-95a5-ec1d5909c786   SNAPSHOT_FIDELITY_MEMORY   72s
lab-n      pend-u1                                TAG_SCOPE_ATESPACE    Ready   gs://ate-snapshots/atespaces/lab-n/tags/cfefcc57-5a75-418c-ae29-424958706266   SNAPSHOT_FIDELITY_MEMORY   69s
The complete file. The first two tags are the golden tags of two templates, and the other three are tags that we made of two actors.

We measured three tags in the node-stop cases, on a second x86 server guest (16 vCPU, 23 GB), with one run for each case. A tag of an actor that was suspended with --mode sync was Ready in less than 1 s. So was a tag of an actor whose newest snapshot was still PENDING on the node, and it held that newest state. Then we stopped the node. A new actor from each tag served its first request 1.2 s after the create command, on the other node. That first request took 0.48 to 0.58 s. Raw runs

A tag has three limits. It is as old as the suspend that it copied, so later work is not in it. The actor that you create from it has a new name. And you cannot make it after the fact: create tag on an actor that was held on the stopped node failed with must be ACTOR_STATE_SUSPENDED to be tagged. Section 8.2 uses tags in its runbook for a stopped node.

Do not delete a golden tag

The golden tag of a template is in the same list. Its scope is TAG_SCOPE_PUBLISHED, and its name is a uid. kubectl ate delete tag accepts it, and our own clean-up made that mistake. It deleted every tag of the atespace, and the golden tags of two templates went with them.

outputa template after its golden tag was deletedFull log
## get actor-templates
ATESPACE   NAME         GOLDEN TAG                             ERROR   AGE
lab-n      ballast-0    d8328610-2fdb-4974-a6ab-7c8671586d37           170m
lab-n      ballast-50   09fee120-cd19-4121-8bae-5623fcbe70eb           170m
## get tags
ATESPACE   NAME   SCOPE   STATE   SNAPSHOT   FIDELITY   AGE
## create actor from template ballast-50
Error: failed to create actor: rpc error: code = FailedPrecondition desc = Tag not found
…
Eight lines of the file. The lines that create two new templates are cut.

The template looks healthy and is broken. get actor-templates still lists the old golden tag id, and the ERROR column is empty. Each create actor --template fails with Tag not found. We saw this once, on the same guest. We made new templates to continue, and we did not test whether a golden tag can be restored.

CRASHED and RevertActor

A crashed actor stays crashed until you call RevertActor, which returns it to its last completed snapshot and drops every call after it.

A node of your cluster restarts in the night. In the morning, some sessions answer 503 with a text about AssignWorker. One answers 503 with a text about an upstream connection. kubectl get pods shows every pod as Running. Substrate will not repair any of these sessions by itself, and a new request will not either.

When you finish this section, you can tell from an error text which state an actor is in. You can also bring the actor back with RevertActor and say which calls it lost.

Every way we reached CRASHED

ACTOR_STATE_CRASHED: the state of an actor whose sandbox, worker, or restore was lost. The actor has no worker. Its status.crash field holds a time and a message.

What we didFirst response to the callerstatus.crash.message
Deleted the worker pod of a running actor503, AssignWorker prerequisite not metworker pod went away while hosting the actor
Removed the snapshot on the node before the upload, then sent a request500, error resuming actor lab-c/x1resume failed: atelet Restore: snapshot … has no usable local copy and no uploaded copy
Restarted the node before the upload, then sent a request500, error resuming actor lab-c/r1resume failed: atelet Restore: worker restore failed with a retryable error 3 times in a row, giving up
Let the actor allocate memory past its limit503, upstream connect error, then 503, AssignWorker prerequisite not metkilled for exceeding its memory limit
Restarted the node under a running actor503, AssignWorker prerequisite not metnot recorded by us

The first row is the plain case. The actor object says what happened.

yamlthe status of my-counter-1 after we deleted its worker podFull log
status:
  assignedNode: kind-control-plane
  crash:
    crashTime: "2026-11-12T10:30:11.170436971Z"
    message: worker pod went away while hosting the actor
  lastAssignedGeneration: 3
  …
  state: ACTOR_STATE_CRASHED
The snapshot list between the two parts is cut.

The second and third rows are the cost of an asynchronous suspend. In both, the newest snapshot was SNAPSHOT_STORAGE_STATE_PENDING, so one node held the only copy. In both, the actor still looked healthy: its state was ACTOR_STATE_SUSPENDED. The request that tried to resume it found the loss and crashed it.

outputa pending snapshot is lost, and the next request crashes the actorFull log
2026-11-12T11:30:18.606Z	x1	removed-node-local-snapshot	uid 085b45fb-edb9-4523-ad30-3da55b3f34f0
2026-11-12T11:30:18.708Z	x1	call-after-loss	500	0.082402	error resuming actor lab-c/x1
2026-11-12T11:30:18.911Z	x1	state	ACTOR_STATE_CRASHED	kind-control-plane	…
2026-11-12T11:30:23.997Z	x1	call-again	503	0.063957	actor lab-c/x1 unavailable: AssignWorker prerequisite not met for Actor: lab-c/x1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED)
…
2026-11-12T11:30:25.241Z	x1	call-after-revert	200	0.416152	count=4 mb=50 fill=random sum=6296 process_age_s=0.8
2026-11-12T11:30:25.434Z	x1	state	ACTOR_STATE_RUNNING	kind-control-plane	…
Six lines for the actor x1. The snapshot column is cut from the two state lines.

Substrate did not use the older, completed snapshot by itself. The actor had one in the object store, and the request still failed.

The node restart has a detail that the table hides.

We measured one node restart on the x86 server guest. The Kubernetes API answered after 8 seconds, and all pods ran after 147 seconds. We sent the request less than two seconds later. The crash message does not name a lost snapshot. It says that the restore failed three times because the worker had not registered yet. So a request that arrives too early after a node restart can crash a suspended actor, and we did not test a later request. Raw runs

One event did not crash anything. We deleted the atelet pod while a snapshot was pending, and the next request returned 200 with the count intact. The snapshot is on the disk of the node, not in atelet.

What the caller sees

Two texts tell you that an actor is crashed.

  • error resuming actor <atespace>/<name>, with status 500. Only the request that finds the loss gets this text.
  • actor <atespace>/<name> unavailable: AssignWorker prerequisite not met for Actor: <atespace>/<name> (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED), with status 503. Every later request gets this text, and it arrives at once.

kubectl ate resume actor is refused with the same text and the gRPC code FailedPrecondition.

outputresume is refused for a crashed actorFull log
2026-11-12T10:30:17.301Z	resume-while-crashed	0.207s	Error: failed to resume actor: rpc error: code = FailedPrecondition desc = AssignWorker prerequisite not met for Actor: ate-demo-counter/my-counter-1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPEN
One line. Our trace kept the first 200 characters of the output, so the end of the state name is missing.

No retry helps. Do not put this 503 in the same retry loop as a 503 from a full pool.

Running in the state, dead in the sandbox

One failure does not reach ACTOR_STATE_CRASHED at all. We told an actor to call os.Exit(3).

outputthe process exits and the actor stays RUNNINGFull log
2026-11-12T11:30:45.229Z	j1	exit-request	200	0.065314	exiting with code 3
2026-11-12T11:30:47.441Z	j1	state	ACTOR_STATE_RUNNING	kind-control-plane	…
2026-11-12T11:30:47.518Z	j1	call	503	0.058459	upstream connect error or disconnect/reset before headers. reset reason: connection termination
…
2026-11-12T11:31:00.122Z	j1	suspend-attempt	Error: failed to suspend actor: rpc error: code = Internal desc = internal server error: workflow failed at step CallAteletSuspend: actor lab-c/j1 crashed…
2026-11-12T11:31:00.333Z	j1	state	ACTOR_STATE_CRASHED	kind-control-plane	…
Five lines for the actor j1. Five more pairs of the second and third line follow in the file. The snapshot column and the end of the error are cut.

We measured one actor for each kind of death on the x86 server guest. After the exit, the state was ACTOR_STATE_RUNNING in 6 of 6 samples over 14 seconds, and all 6 requests got the 503 from Envoy. A memory kill is different: Substrate recorded the crash 2.3 seconds after the request that started the allocation. One request in that gap got the Envoy text. Raw runs

Fourteen seconds do not prove much, so we repeated the exit and watched for 20 minutes. One actor got no request. A second actor got one request each 60 seconds.

output20 minutes after the exit, and then one suspendFull log
2026-11-12T12:45:20.264Z	e1	exit-request	200	0.068987	exiting with code 3
2026-11-12T12:45:36.467Z	e1	state	ACTOR_STATE_RUNNING	kind-control-plane	…
…
2026-11-12T13:05:57.588Z	e1	call-after-watch	503	0.070948	upstream connect error or disconnect/reset before headers. reset reason: connection termination
2026-11-12T13:05:57.886Z	e1	suspend-attempt	Error: failed to suspend actor: rpc error: code = Internal desc = internal server error: workflow failed at step CallAteletSuspend: actor lab-v/e1 crashed…
2026-11-12T13:05:58.168Z	e1	state	ACTOR_STATE_CRASHED	kind-control-plane	…
2026-11-12T13:05:58.604Z	e1	after-revert	ACTOR_STATE_SUSPENDED	-	…
Six lines for the actor e1, which got no request. The 80 later state lines of e1 are cut, and all of them say ACTOR_STATE_RUNNING. The snapshot column and the end of the error are cut.

We measured two actors on the x86 server guest (24 vCPU, 47 GB, nested KVM), for 20 minutes 36 seconds after the exit. Both were ACTOR_STATE_RUNNING in every sample, 81 and 80 samples at 15 second intervals. All 20 requests to the second actor returned the 503 from Envoy. The first suspend after the watch failed and marked the actor crashed. A revert returned it to ACTOR_STATE_SUSPENDED, and the next call returned 200 with the count of the last durable snapshot. Raw runs

So Substrate did not find a process exit by itself in 20 minutes, with requests or without. Each dead actor also kept one CPU of its worker for all that time, which section 4.5 counts. We did not watch longer, and we did not restart the worker.

In both runs a suspend found the exit. The checkpoint failed, and the failed suspend marked the actor crashed.

Your harness must cover the exit case. If requests return upstream connect error and the state is ACTOR_STATE_RUNNING, call SuspendActor and expect it to fail. A harness that suspends after every turn does this without extra code.

An actor on a node that stops

A node that stops and stays down gives three more cases, and none of them starts as ACTOR_STATE_CRASHED. We ran them on a cluster with two worker nodes and stopped one of the two. Placement has the timeline and the scheduler side.

The actor when the node stoppedState that you seeText of the request or the call
Suspended, and then a request arrivesACTOR_STATE_RESUMING for minutes, then ACTOR_STATE_CRASHED504 at the client timeout, then 503 actor lab-n/u1 unavailable after 5 to 8 s
RunningACTOR_STATE_RUNNING503 upstream connect error or disconnect/reset before headers. reset reason: connection termination
Either one, when you call revertACTOR_STATE_RESUMING or ACTOR_STATE_REVERTINGanother operation is in progress for this actor, or is not in a revertable state (got: ACTOR_STATE_RESUMING, or while calling atelet Terminate

We measured these cases on the second x86 server guest (16 vCPU, 23 GB), with gVisor and one actor for each case. A suspended actor with a complete snapshot stayed in ACTOR_STATE_RESUMING for 5 min 33 s from its first request. It was crashed 5 min 44 s after the stop command. A suspended actor with a pending snapshot was still in ACTOR_STATE_RESUMING 10 min 42 s after the stop command. It had received no request in the last 8 min 43 s. A running actor stayed ACTOR_STATE_RUNNING for the 10 minutes and the 150 requests of our probe. Raw runs

outputa revert is refused twice while the node is downFull log
2026-11-12T12:22:55.258Z	node-stop	docker stop multi-worker2
2026-11-12T12:23:15.930Z	first-request	504	10.054853		|	ACTOR_STATE_RESUMING	multi-worker2	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:-,SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen3:multi-worker2
2026-11-12T12:23:16.115Z	revert-attempt-1	Error: failed to revert actor: rpc error: code = Aborted desc = another operation is in progress for this actor 
…
2026-11-12T12:33:37.802Z	revert-attempt-2	Error: failed to revert actor: rpc error: code = FailedPrecondition desc = Actor lab-n/p3 is not in a revertable state (got: ACTOR_STATE_RESUMING, want one of [ACTOR_STATE_RUNNING ACTOR_STATE_CRASHED], or ACTOR_STATE_SUS
2026-11-12T12:33:38.407Z	probe-after-revert	503	0.193436	actor lab-n/p3 unavailable: AssignWorker prerequisite not met for Actor: lab-n/p3 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED)	|	ACTOR_STATE_CRASHED	multi-worker2	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:-,SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen3:multi-worker2
Five lines for the actor p3. The requests and the 10 minutes between the two reverts are cut. Our trace kept the first 200 characters of each CLI error.

Three rules follow from these runs.

  • ACTOR_STATE_RESUMING that lasts is a symptom. A resume of a small actor takes less than a second, and this state lasted for minutes. In the first run the crash came one second after Kubernetes evicted the worker pods, 5 minutes after the node was NotReady. Without a request, nothing moved the actor out of the state.
  • A running actor on a stopped node is not marked crashed. The caller gets the text of a process that exited. To tell the two cases apart, run kubectl ate get workers. On a stopped node the worker is WORKER_STATE_UNAVAILABLE, or it is not in the list.
  • A revert did not help while the node was down. It failed or had no lasting effect in 4 of 4 attempts on three actors. The revert of the running actor left it in ACTOR_STATE_REVERTING. Each request then got 503 with (got: ACTOR_STATE_REVERTING, want ACTOR_STATE_SUSPENDED).

A second revert is the way out of ACTOR_STATE_REVERTING, and only after the node is back. RevertActor accepts an actor that is already in that state, and it continues from the step where the earlier attempt stopped. A later set of node-stop cases tested it.

We measured one actor that was suspended with --mode sync, on a second x86 server guest (16 vCPU, 23 GB), in one run with five reverts. The first revert started 10 s after the stop command, failed after 20.2 s, and left the actor in ACTOR_STATE_REVERTING. Three more failed in 0.2 s each, at 50 s, at 71 s, and at 371 s. Then we started the node. Its atelet ran 15 s later, and the actor was ACTOR_STATE_CRASHED by then. The fifth revert returned in 0.2 s, and the next request returned 200 in 0.47 s on the other node. Raw runs

outputfour reverts fail while the node is down, and the fifth worksFull log
2026-11-12T14:51:05.501Z	t=30.6	revert-1-right-after-stop	20.2 s | Error: failed to revert actor: rpc error: code = Unavailable desc = while calling atelet Terminate: …
2026-11-12T14:51:24.853Z	t=50.0	revert-2-after-workers-unavailable	0.2 s | Error: failed to revert actor: rpc error: code = Unavailable desc = while calling atelet Terminate: …
…
2026-11-12T14:56:45.527Z	t=370.7	revert-4-after-eviction	0.2 s | Error: failed to revert actor: rpc error: code = Unavailable desc = while calling atelet Terminate: …
…
2026-11-12T14:57:00.955Z	t=386.1	atelet-on-node-running	atelet-gsmxg:Running atelet-tfq5g:Running 
2026-11-12T14:57:01.180Z	t=386.3	state-before-revert-5	ACTOR_STATE_CRASHED	multi-worker2	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
2026-11-12T14:57:01.440Z	t=386.6	revert-5-after-node-return	0.2 s | ATESPACE   NAME   TEMPLATE      STATE                   WORKER POD   WORKER IPS   VERSION   AGE lab-n      i9     lab-n/nb-50   ACTOR_STATE_SUSPENDED   <none> …
Six lines for the actor i9. The columns are the time, the seconds from the stop command, the event, and its detail. Each error is cut after the name of the call that failed, and the actor row of the fifth revert is cut.

So a repeated revert does not finish the job while the node is down. In the other cases, 3 to 28 more reverts of an actor in ACTOR_STATE_REVERTING all failed. Each failed revert is one more call to an atelet that is not there.

The result after the return has a caveat. In four earlier cases the actor still showed ACTOR_STATE_REVERTING 216 to 223 s after the node start, and we sent no command in that time. In those cases the pool pods had been evicted, and the node came back with no pool pod. Our clean-up then removed those actors, and we did not record each result. So the state cleared by itself after the return in one run, and it did not in four. After a node returns, look at the state, and send the revert again if it is still ACTOR_STATE_REVERTING.

The path that the first runs left open is also measured. A revert that waits until the workers show WORKER_STATE_UNAVAILABLE frees one kind of actor: suspended with --mode sync, and with no request since the stop. It took 7.1 s, and the next request ran on the other node. Section 8.2 has the runbook for every case.

Do not revert an actor that you suspended in the default mode. Its newest snapshot is still PENDING on the stopped node, and it comes back with the node. In one run the node returned after 81 s. A request with no revert then returned 200 in 0.36 s, with count 6 after 5. A second actor in the same state got a revert first. Its next reply had count 4, so the two calls after its last durable snapshot were lost.

RevertActor

RevertActor: the call that discards the current execution of an actor and returns it to ACTOR_STATE_SUSPENDED at its last completed durable snapshot. It accepts a crashed actor, a running actor, and a suspended actor whose newest snapshot is on a node. It also accepts an actor in ACTOR_STATE_REVERTING, so that you can send a revert again. In our run that second revert worked only after the node of the actor was back.

A revert stops any sandbox, removes the snapshot files on the node, and deletes an upload that was not complete. The actor comes back with no assigned node, and the next request resumes it from the object store.

RunLast completed snapshotCalls made after itFirst reply after the revert
Worker pod deletedcount 89, 10, 11count 9
Snapshot on the node removedcount 34, 5count 4
Node restartedcount 34, 5count 4

The calls in the third column are gone. The actor answers as if they never happened.

We measured one revert of the counter actor on the x86 server guest: kubectl ate revert actor returned in 0.22 seconds. The first request after a revert took 0.38 to 0.42 seconds in three runs. After the node restart, that first request waited 5 seconds and got no worker has room for the actor, although all pods ran. A request 78 seconds later returned 200. Placement explains that 503: for some seconds after the control plane returns, no worker of the pool is active. Raw runs

A revert rewinds the actor only. A tool call that the actor made after the snapshot stays made, and an external volume keeps its contents. We did not test external volumes.

Fig. 3.10my actor returns 503 or 500decision
From the error text to the fix A decision figure with four rows. A 500 with the text error resuming actor and a 503 with the text AssignWorker prerequisite not met both mean ACTOR_STATE_CRASHED, and the fix is kubectl ate revert actor, which loses the calls since the last completed snapshot. A 503 with the text upstream connect error means the process exited while the state is still ACTOR_STATE_RUNNING, and a synchronous suspend moves it to CRASHED before the revert. An actor on a stopped node returns the same text and also stays RUNNING. A suspend or a revert does not help it while the node is down, and figure 8.2d has the steps for that case. A 503 after 5 seconds with the text no worker has room for the actor means the actor is healthy and the pool is full. WHAT THE CALLER SEES ACTOR STATE WHAT TO DO 500 in 0.08 s error resuming actor lab-c/x1 503 in 0.06 s AssignWorker prerequisite not met got: ACTOR_STATE_CRASHED 503 in 0.06 s upstream connect error or disconnect reset reason: connection termination 503 after 5.06 s no worker has room for the actor CRASHED resume actor is refused RUNNING the process exited SUSPENDED the pool is full revert actor back to the last COMPLETED snapshot, later calls are lost suspend --mode sync it fails and marks the actor CRASHED add workers or try again later, see section 4.4 A stopped node gives the third text too. Then send no revert: see figure 8.2d. Seconds and names are from our runs on the x86 server guest, Envoy dataplane.
The text of the error tells you the state of the actor, and the state tells you the one call that helps. Read each row left to right. Rose boxes are errors and failed states, and the last row is a full pool with a healthy actor. Seconds are from our runs on the x86 server guest.

Our position: decide in your harness what a crash means, because Substrate will not. After our node restart, my-counter-1 stayed crashed until we reverted it by hand. A revert is safe to automate for an actor whose last calls can be sent again without a second effect. A tool call that already ran stays done, so the tool must ignore a repeat. For any other actor, suspend with --mode sync at each point that you cannot lose.

Atespaces and names

An atespace groups actors, templates, and tags in the Substrate API, and it is not a Kubernetes namespace, although the two often share a name.

You ran kubectl get ns to find where your actors live. You found a namespace with the name of your demo, with worker pods in it and no actor. Then you created a second atespace for a new team, and its template still ran on the pool of the first team. Both results follow from one fact: Substrate keeps two lists of names, and it connects them with labels only.

When you finish this section, you can create an atespace and name an actor and a template in it. You can also select a worker pool from a template, and find a snapshot in the object store from its URI.

An atespace is not a namespace

Atespace: the group that an actor, an ActorTemplate, and a tag belong to in the Substrate API. An atespace is a row in Postgres. It must exist before you create anything in it, and you can delete it only when it is empty.

ObjectWhere it livesHow you list it
AtespaceSubstrate API, globalkubectl ate get atespaces
ActorSubstrate API, in one atespacekubectl ate get actors -a <atespace>
ActorTemplateSubstrate API, in one atespacekubectl ate get actor-templates -a <atespace>
TagSubstrate API, in one atespacekubectl ate get tags -a <atespace>
Access policySubstrate API, one for each atespacesection 5.2
WorkerPool, worker podsKubernetes, in one namespacekubectl get workerpools -n <namespace>
Worker recordSubstrate API, globalkubectl ate get workers

Our test setup made both kinds of object with one name. That is our choice, and Substrate did not ask for it.

bashone namespace, one WorkerPool, and one atespace, all named by usFull log
kind: Namespace
metadata:
  name: lab-c
…
kind: WorkerPool
metadata:
  name: ballast
  namespace: lab-c
  labels:
    workload: ballast
…
kubectl apply -f pool.yaml
kubectl ate create atespace lab-c 2>&1 | tail -1
Lines from the script that prepared the resume tests. The pool spec and the lines between the three parts are cut.

kubectl apply wrote the namespace and the pool to Kubernetes. kubectl ate create atespace wrote one row through ate-api-server. The quickstart demo does the same with the name ate-demo-counter, so a new user sees one name and assumes one object.

Fig. 3.11an atespace against a Kubernetes namespacetree
An atespace and a Kubernetes namespace hold different objects Two trees side by side. On the left, in the Substrate API and stored in Postgres, the atespace lab-c holds the ActorTemplate ballast-200, the actor b200-1 and the golden tag of the template. On the right, in Kubernetes, the namespace lab-c holds the WorkerPool ballast with the label workload: ballast and its two worker pods. A dotted line joins the workerSelector of the template to the label of the pool, because pool selection uses labels and does not check the namespace. SUBSTRATE API, ROWS IN POSTGRES KUBERNETES, OBJECTS IN ETCD Atespace lab-c kubectl ate create atespace lab-c ActorTemplate ballast-200 workerSelector: matchLabels workload: ballast Actor b200-1 addressed as lab-c/b200-1 Tag b9f97b82 golden tag, named by the template uid Namespace lab-c kubectl create namespace lab-c WorkerPool ballast labels workload: ballast replicas 2, class gvisor ballast-65cf95d875-bzgjd worker pod, 2 CPU, 3 GiB ballast-65cf95d875-f65dl worker pod, 2 CPU, 3 GiB The dotted line is the only link: the selector of the template matches the labels of the pool. The two names lab-c are equal because we chose them so. Substrate does not compare them.
An atespace holds templates, actors, and tags, a namespace holds worker pools, and only a label selector joins them. Read each tree from the top down. The left tree is in Postgres and the right tree is in Kubernetes. The dotted arrow is the workerSelector of the template. Names are from our resume tests.

The atespace is the boundary for access. A role in an atespace covers every actor and every template in it, and no grant exists for one actor. Do not use the name of a deleted atespace again. The documentation warns that a request authorized for the old atespace can reach the new one.

Pool selection crosses namespaces

A template selects its workers with workerSelector.matchLabels. The scheduler compares those labels with the labels on the WorkerPool object. It does not read the labels of the pods, and it does not read the namespace of the pool. Each worker carries a copy of the labels of its WorkerPool object, and the scheduler matches against that copy.

Three rules follow from the code and the documentation.

  • Only equal labels match. No set expressions exist.
  • An actor can add its own selector. The scheduler applies both selectors together, so the selector of the actor only narrows the choice. A change takes effect at the next resume.
  • A template with no selector can run on every pool.

So the namespace of a pool is not a fence. A template in any atespace can name the labels of any pool, and Kubernetes RBAC on that namespace does not stop it. No document at v1.0.0 states this rule. It follows from the code, and our run below confirms it.

We ran it. We created an atespace xa and no Kubernetes namespace of that name. A template in xa selected workload: vpool, the label of a pool in the namespace lab-v.

outputan actor of atespace xa on a worker pod of namespace lab-vFull log
ATESPACE   NAME   TEMPLATE     STATE                 WORKER POD                     …
xa         x1     xa/cross-0   ACTOR_STATE_RUNNING   lab-v/vpool-69595c64bf-p4slt   …
Both lines of the file. The last three columns are cut.

The template built its golden snapshot on that pool, and the actor ran on a worker pod of lab-v.

A selector that matches no pool is the opposite mistake, and Substrate does not refuse it. We created a second template in xa with the label workload: no-such-pool. The create call succeeded. The template never got a golden tag, and its ERROR column stayed empty. A request to an actor of that template failed.

outputa request to an actor whose template matches no poolFull log
2026-11-12T12:51:40.304Z	call-unmatched-selector	503	5.060180	actor xa/x2 unavailable: no worker has room for the actor
One line of the trace.

We measured both templates once on the x86 server guest (24 vCPU, 47 GB, nested KVM). The first request to the actor on the pool of lab-v returned 200 in 0.26 seconds. The request with the wrong selector waited 5.06 seconds for its 503. Raw runs

That is the text of a full pool, word for word. ate-api-server logged the same reason for the template 8 times in 40 seconds of its log, and the template showed no error. When you see no worker has room for the actor, compare the selector with the pool labels before you add workers. Section 8.3 has the row.

kagent shows the convention that fills this gap. It writes the name and the namespace of the pool into two labels, and its templates select both.

yamlthe selector that kagent writes into its templateFull log
workerSelector:
  matchLabels:
    kagent.dev/worker-pool-name: kagent-default
    kagent.dev/worker-pool-namespace: kagent
The last four lines of the file.

The second label looks like a namespace check, and it is a label like every other. A pool in another namespace with the same two labels would match.

The worker list has one filter for each list of names.

outputthe filters of kubectl ate get workersFull log
-a, --atespace string        filter worker pods hosting actors in a specific atespace
…
-n, --namespace string       scope output to a specific Kubernetes namespace
--sandbox-class string   filter by sandbox class (e.g. gvisor, microvm)
-l, --selector string        filter by worker pool labels
Four lines of the help text that the command printed.

Names

Every name in the Substrate API is a DNS label: lowercase letters, digits, and hyphens, at most 63 characters. A name is fixed at create time.

WhatFormExample from our runs
Actor address<atespace>/<name>ate-demo-counter/my-counter-1
Template reference<atespace>/<name>lab-c/ballast-200
Actor uidassigned by the server6f80f612-7547-4f5b-b3ab-831595621806
Worker namethe uid of the worker podc572e96d-dd9b-4500-b0e3-880a98d9f61d
Golden tag namethe uid of the templateb9f97b82-b187-419d-8789-6c8c7b8a6c8c
kagent actorsession-<session id>session-01a1cf43-d760-747a-b907-5e0640126e4e

The actor address is the form that you will read most. Error texts use it, kubectl ate takes its two halves as -a and the name, and the router reads it from a request header. Section 6.1 covers that header.

The uid matters when you delete an actor and create it again with the same name. The new actor gets a new uid. Snapshot paths use the uid, so the new actor cannot resume from the state of the old one.

The snapshot URI layout we saw

Each snapshot in an actor object has a snapshotUri. The atespace and the owner are part of the path.

yamlthe two snapshot URIs of my-counter-1 after its first suspendFull log
snapshotUri: gs://ate-snapshots/atespaces/ate-demo-counter/tags/ad19ee28-1519-432a-bcc5-40cf99cce3ca
…
snapshotUri: gs://ate-snapshots/atespaces/ate-demo-counter/actors/6f80f612-7547-4f5b-b3ab-831595621806/snapshots/8faccdff-15cc-493d-8474-5ebcb1c18d56
Two lines of the file. The first is the golden snapshot, and the second is the snapshot of the actor.
OwnerLayout
A tag<location>/atespaces/<atespace>/tags/<tag uid>
An actor<location>/atespaces/<atespace>/actors/<actor uid>/snapshots/<snapshot uuid>
An actor, on the node/var/lib/ate/snapshots/<actor uid>/local-checkpoint/<snapshot uuid>

We saw three details that the layout does not tell you.

  • On a kind install the location is gs://ate-snapshots/, although the store is the rustfs pod in the cluster.
  • kagent sets a location for each template, and its URIs start with s3://ate-snapshots/kagent/. That field is a preview feature in the upstream project.
  • The last part of a golden URI is the uid of the tag. It is not the name of the tag, which is the uid of the template.

The atespace is in the path for a reason. An object store can grant access by path prefix, so you can give one team read access to the snapshots of one atespace. We did not test such a grant.

Our position: use one name for the atespace and the namespace when one team owns both, as the demos do. Then treat the labels of your pools as placement, and not as a fence. Any atespace can name them, and we found no setting at v1.0.0 that limits a pool to one atespace. Choose the labels on purpose, and check which templates name them.

Part 4

Workers and Capacity

A WorkerPool decides how many actors run at one time, and a full pool answers with a 503 after five seconds.

  1. 4.1WorkerPool
  2. 4.2Sandbox classes: gVisor and microVM
  3. 4.3Placement: which worker gets the actor
  4. 4.4Request parking
  5. 4.5Sizing a pool

WorkerPool

A WorkerPool is a set of worker pods that you size, and each pod reports a capacity taken from its own CPU and memory limits.

You create an actor, send it a request, and look for its pod. There is no pod with the name of the actor. The actor runs inside a worker pod that existed before the actor did, and that hosts other actors too. The WorkerPool is the object that made that pod.

When you finish this section, you can write a WorkerPool, read the output of kubectl ate get workers, and say where each capacity number comes from.

Three CRDs, and no Actor among them

WorkerPool: a namespaced Kubernetes object that asks for a number of worker pods of one sandbox class.

Substrate adds three CRDs to your cluster, all in the group ate.dev at version v1. A WorkerPool is the only one you write often. A SandboxConfig names the sandbox binaries for a class, and the install creates a default for each class. A CSIDriverConfig belongs to a preview feature for external volumes.

outputthe CRDs after the installFull log
csidriverconfigs.ate.dev   Cluster      v1(storage)   2026-11-12T10:05:33Z
sandboxconfigs.ate.dev     Cluster      v1(storage)   2026-11-12T10:05:33Z
workerpools.ate.dev        Namespaced   v1(storage)   2026-11-12T10:05:33Z
Three lines from the state dump that we took after the quickstart.

Actors, ActorTemplates and atespaces are absent from that list. They are rows in Postgres that ate-api-server serves, so kubectl get cannot show them. Use kubectl for the pool and kubectl ate for all that runs on the pool.

The pool we used

This is the pool behind most measurements in this manual. It asks for two gVisor workers, each with 2 CPUs and 3 GiB.

yamla WorkerPool with two workersFull log
apiVersion: ate.dev/v1
kind: WorkerPool
metadata:
  name: ballast
  namespace: lab-c
  labels:
    workload: ballast
spec:
  replicas: 2
  workerImage: localhost:5001/ateworker-gvisor@sha256:ad6957f41bccaa8bb5f3eb5b34b7812babb862f1a3187a11578c21a7d799491c
  sandboxClasses:
  - name: gvisor
  template:
    resources:
      limits:
        cpu: "2"
        memory: 3Gi
      requests:
        cpu: 500m
        memory: 3Gi
The Namespace object above the pool is cut.

Four fields carry the meaning:

  • spec.replicas is the number of worker pods. The pool has a scale subresource, so kubectl scale and an autoscaler can change it.
  • spec.workerImage is the worker binary for the class. Use ateworker-gvisor for gvisor and ateworker-microvm for microvm.
  • spec.sandboxClasses takes exactly one entry at v1.0.0, and you cannot change its name later. Sandbox classes compares the two.
  • spec.template.resources.limits is the capacity of each worker. It is the most important field in the object.

The label workload: ballast sits on the pool object, under metadata.labels. An ActorTemplate selects pools with workerSelector.matchLabels, and the match reads the labels of the pool object. Labels under spec.template.labels go to the Deployment and its pods, and they do not affect which actors the pool accepts.

A selector is the only link between a template and a pool. The namespace of the pool and Kubernetes RBAC do not limit which templates can select it. An actor can add its own workerSelector, which narrows the selector of the template and takes effect at the next resume. Put a label on each pool that only the intended templates use.

From the pool to a registered worker

Fig. 4.1from a WorkerPool to two registered workersflow
From a WorkerPool to two registered workers The WorkerPool ballast in namespace lab-c asks for two replicas with a limit of 2 CPUs and 3 GiB. ate-controller reads it, writes a Deployment, and creates one Worker record in ate-api-server for each pod. atelet on the node registers each worker, and the record becomes WORKER_STATE_ACTIVE. Each of the two worker pods then reports 2 of 1000 actors, 2000 of 2000 millicores and 512Mi of 3008Mi. YOU WRITE SUBSTRATE RECONCILES WorkerPool ballast replicas: 2 limits: cpu 2, memory 3Gi ate-controller one replica ate-api-server Worker b17a6492 Worker ee4ba8b4 1 reads 2 creates Deployment ballast label ate.dev/worker-pool 1 writes ONE NODE worker pod bzgjd ACTORS 2/1000 CPU 2000/2000 MEMORY 512Mi/3008Mi worker pod f65dl ACTORS 2/1000 CPU 2000/2000 MEMORY 512Mi/3008Mi atelet registers each worker reports its runtimes two pods 3 registers 4 WORKER_STATE_ACTIVE
A WorkerPool becomes a Deployment of worker pods, and each pod becomes a Worker record that reports the limits of the pod as capacity. Read left to right. Grey is Kubernetes, blue is the Substrate control plane, violet is the worker side. The numbers under each worker are one row of kubectl ate get workers.

Four steps separate your YAML from a worker that can take an actor:

  1. ate-controller reads the WorkerPool and writes a Deployment with the same replica count. Each pod has the label ate.dev/worker-pool.
  2. ate-controller watches those pods and creates one Worker record for each in ate-api-server. The name of the record is the UID of the pod.
  3. atelet on the node registers the worker and reports which sandbox runtimes it can run.
  4. When the pod is Ready, the Worker moves to WORKER_STATE_ACTIVE. Only an active worker receives actors.

Worker: the record in ate-api-server for one worker pod, with its state, its capacity, and the resources that actors have booked on it.

A Worker has three states. WORKER_STATE_UNAVAILABLE means registered and not Ready. WORKER_STATE_ACTIVE means ready for actors. WORKER_STATE_DRAINING means the pod is terminating, and a worker never leaves that state.

A terminating worker pod is slow on purpose. The controller sets its termination grace period to 3,600 seconds, and the worker gives its actors up to 30 minutes to stop. A rollout of a pool with running actors can take that long.

What kubectl ate get workers shows

We ran the command when four actors with 1 CPU each were running on the pool.

outputboth workers fullFull log
NAME                                   POOL      STATE                 ACTORS   CPU(MILLICORES)   MEMORY         POD                              AGE
b17a6492-9643-42f1-bfdb-8bb88ee46772   ballast   WORKER_STATE_ACTIVE   2/1000   2000/2000         512Mi/3008Mi   lab-c/ballast-65cf95d875-bzgjd   114s
ee4ba8b4-dbfa-4555-a621-7cf7662c1091   ballast   WORKER_STATE_ACTIVE   2/1000   2000/2000         512Mi/3008Mi   lab-c/ballast-65cf95d875-f65dl   114s

Each of the three middle columns is a pair: booked, then capacity.

ColumnCapacity comes fromIn our pool
ACTORSthe worker flag --max-actors, default 10002 of 1000
CPU(MILLICORES)the CPU limit of the worker container2000 of 2000
MEMORYthe memory limit, less what the worker keeps for itself512Mi of 3008Mi

The worker kept 64 MiB of its 3 GiB limit, so it reported 3008Mi. The demo pools with a 1 GiB limit reported 960Mi, the same 64 MiB less.

The booked side is the sum of the limits that each running actor declares in its ActorTemplate. Each actor here declared 1000 millicores and 256 MiB. Two actors booked the whole CPU capacity and a sixth of the memory. The worker is full, and CPU is the reason.

cpuMillicores has two jobs. The worker books it for placement, and the worker also writes it into the sandbox as a CPU quota for each 100 ms period. The documentation says that the node kernel slows the actor to that quota.

The quota is a hard cap. We tested it with a program that runs a fixed number of loop steps, about 5 seconds of work on one free core. It ran on 1 thread and on 4 threads, under four templates. We read the CPU time of the actor from its cgroup on the node, before and after each run.

We measured three runs for each row on a second x86 server guest (16 vCPU, 23 GB). The pool had one worker pod with a Kubernetes limit of 6 CPU. Raw runs

cpuMillicoresThreadsWall time, sHost cores used
250127.1 to 27.60.25
250426.5 to 27.90.25
500112.7 to 13.10.50 to 0.51
500412.4 to 12.80.50
100014.9 to 5.41.01
100045.3 to 5.51.00 to 1.01
200014.1 to 4.51.12
200041.38 to 1.421.96 to 2.01
Fig. 4.2host cores an actor used at four CPU limitschart
Host cores an actor used at four CPU limits A chart of host cores used against the cpuMillicores limit of the template, for a fixed amount of work on 1 thread and on 4 threads, three runs for each point and 24 runs in all. With 1 thread the median was 0.25 cores at 250, 0.50 cores at 500, 1.01 cores at 1000, 1.12 cores at 2000 millicores. With 4 threads it was 0.25 cores at 250, 0.50 cores at 500, 1.00 cores at 1000, 2.00 cores at 2000 millicores. A dashed line marks the limit itself. The points follow that line, and 4 threads got no more CPU than 1 thread below 2000 millicores. The same work took 27.4 seconds at 250 millicores and 5.1 seconds at 1000. The program saw 2 CPUs at every limit. 0 0.5 1 1.5 2 250 500 1000 2000 resources.limits.cpuMillicores of the template host cores used, from the cgroup of the actor 4 threads 2.00 cores at 2000 1 thread 1.12 cores at 2000, one thread fills one core thin dashed line: the limit 0.25, 0.50 and 1.01 cores at 250, 500 and 1000, with 1 thread and with 4 threads same work: 27.4 s at 250, 5.1 s at 1000 5.4 times longer at 250 Each dot is one run, three for each point. The program saw 2 CPUs at every limit.
An actor used 0.25, 0.50, and 1.0 host cores at limits of 250, 500, and 1000 millicores, and it needed four threads to use 2.0 cores at 2000. Read the cores on the vertical axis against the limit of the template. The solid line is 1 thread and the dashed line is 4 threads, with one dot for each run. The thin dashed line is the limit itself.

The actor never used more host cores than its limit. Up to 1000 millicores, four threads did not get more CPU than one thread. The same work took 5.4 times longer at 250 millicores than at 1000. In one row the limit was not the bound: one thread at 2000 millicores used 1.12 cores, because one thread fills one core. Four threads used the whole limit there.

The cap is a cgroup for each actor, inside the cgroup of the worker pod. The worker pod had cpu.max=600000 100000, which is its Kubernetes limit of 6 CPU. Inside it we found one cgroup for each actor, named with the uid of the actor and the suffix _sandbox. Its cpu.max was the template limit, and the gVisor processes of the actor ran in it.

outputthe CPU quota of four actors, as the node reports itFull log
250 25000/100000 usage_usec 41467478 nr_periods 1672 nr_throttled 1649 throttled_usec 428931388 
500 50000/100000 usage_usec 38723256 nr_periods 793 nr_throttled 765 throttled_usec 157096786 
1000 100000/100000 usage_usec 31895780 nr_periods 345 nr_throttled 312 throttled_usec 30529410 
2000 200000/100000 usage_usec 23220151 nr_periods 204 nr_throttled 38 throttled_usec 854079 
The complete file, read after the runs. The columns are the limit in millicores, the quota and the period in microseconds, and the counters of the cgroup.

Two facts about the inside of the sandbox follow, and both can mislead you.

  • The actor does not see its limit. The program counted 2 CPUs at all four limits, and it could not read the cgroup files. A runtime that sizes a thread pool from the CPU count gets 2 at 250 millicores.
  • CPU time read inside a throttled sandbox is wrong. At 250 millicores with 1 thread, the program reported 22 to 24 seconds of CPU time. The same run used 6.9 seconds on the host. Do not use getrusage or /proc inside an actor to measure CPU use. Read the cgroup on the node.

The second fact explains an earlier reading of ours. A demo command reported 2.0 seconds of CPU time in 2.38 seconds under 500 millicores, which looked like no cap. That CPU time came from inside the sandbox, so it does not count. A tool sandbox has that run and the correction.

We tested one actor at a time. We did not test memory limits, or CPU limits with several busy actors on one worker. One row is not explained. Four threads at 2000 millicores used 2.8 seconds of host CPU, and 1 thread used 4.6 to 5.1 seconds for the same work.

A suspended actor books nothing. The command counts running actors only.

Two habits from kubectl do not carry over. The command has no -A flag, and it lists the workers of every namespace by default. It has four filters: -n for one Kubernetes namespace, -l for pool labels, -a for the workers that host actors of one atespace, and --sandbox-class.

A pool without limits reports the whole node

kagent v1.0.0 installs a pool named kagent-default with no resources at all. We listed its worker on a guest with 16 vCPUs and 31 GiB.

outputthe worker of a pool without limitsFull log
NAME                                   POOL             STATE                 ACTORS   CPU(MILLICORES)   MEMORY         POD                                      AGE
6dfb9d1d-cce7-4e42-af53-799a4132fa5d   kagent-default   WORKER_STATE_ACTIVE   0/1000   0/16000           0/32797744Ki   kagent/kagent-default-679c58c6cb-pljdk   19m

The worker claims all 16 CPUs and all the memory of the node. The Substrate documentation says that a worker without limits reports zero and has no bound. Our run disagrees: the worker read the size of the node and reported that. A user reported the same on a larger cluster.

The result is a capacity that nothing backs. The pod has no limit, so Kubernetes reserves nothing for it. A second pool on the same node would claim the same 16 CPUs again. The control plane, the dataplane and Postgres run on that node too. kagent 1.0 on Substrate shows what that worker did under load.

Our position: always set limits on a pool, and set the memory request equal to the memory limit. Then the memory that the worker reports is memory that Kubernetes has reserved. Kubernetes reserves CPU only up to the CPU request. The example above requests 500 millicores and reports 2 CPUs. Set the CPU request equal to the limit when actors must get the CPU that they book. Sizing a pool turns that rule into numbers.

One more fact matters before you give a pool to a team. A worker pod runs as root with SYS_ADMIN and NET_ADMIN, and its seccomp profile is unconfined. The sandbox inside the pod isolates the actor. The pod does not isolate the worker from the node, so run pools on nodes that you trust.

Sandbox classes: gVisor and microVM

A pool runs one sandbox class, and an actor stays on the class of its first snapshot for the rest of its life.

A security reviewer asks one question about your agent platform: is each agent in a container or in a virtual machine? With Substrate the honest answer is neither one, and the choice is yours. An actor runs in a gVisor sandbox or in a small virtual machine. The choice is a field of the WorkerPool, and it is hard to change later.

When you finish this section, you can choose a class for a pool and check that a node can run it. You can also name the behaviours that differ between the two classes.

Most measurements in this manual come from the gvisor class. On the x86 server guest we also ran one test actor on both classes, at 0, 200, and 500 MiB of memory. This section reports that comparison, and it names what stays untested.

Two classes, one field

Sandbox class: the kind of isolation that the workers of a pool give to each actor. The value is gvisor or microvm.

You set the class in spec.sandboxClasses[0].name of the WorkerPool. The set is closed. The API accepts only those two names, and a plain Linux container is not an option.

Fig. 4.3what sits between an actor and the node in each sandbox classcomparison
What sits between an actor and the node in each sandbox class Two columns of stacked layers, read from the node at the bottom to the actor at the top. In the gvisor class, a worker pod runs ateworker-gvisor, which starts one runsc Sentry for each actor, and every container of the actor shares that Sentry. In the microvm class, the node must have the device /dev/kvm, a worker pod runs ateworker-microvm, which starts Cloud Hypervisor and virtiofsd, and the containers of the actor run on a guest kernel with one cgroup each. CLASS GVISOR CLASS MICROVM actor containers limits apply to the actor as a whole runsc Sentry a kernel in user space, one for each actor worker pod, ateworker-gvisor root, SYS_ADMIN, NET_ADMIN node any Linux node actor containers one guest cgroup for each container guest kernel Kata guest on Cloud Hypervisor, virtiofsd worker pod, ateworker-microvm root, SYS_ADMIN, NET_ADMIN node with /dev/kvm atelet offers it as ate.dev/kvm snapshot of files: runsc checkpoint bundles snapshot of files: tar archives minimum memory limit: none minimum memory limit: 128 MiB
A gVisor actor shares the node kernel through a user-space kernel, and a microVM actor runs on its own guest kernel under KVM. Read each column from the node at the bottom to the actor at the top. Violet is the worker side and green is the actor. The two lines under each column are differences that you meet in practice.
Areagvisormicrovm
Isolationrunsc, a kernel in user spacea KVM guest: Kata Containers on Cloud Hypervisor
Node needsany Linux nodea node with /dev/kvm
CPUs that the actor seesat least 2, whatever the limitthe limit, rounded up, at least 1
Smallest memory limitnone128 MiB
Limits for each containeraccepted and ignoredenforced in the guest
Files in a snapshotrunsc checkpoint bundlestar archives

What the node must provide

A gVisor worker runs on any Linux node. That is why it runs on a laptop.

A microVM worker needs the device /dev/kvm. atelet offers the device to Kubernetes as the resource ate.dev/kvm, only on nodes that have it. The controller adds a request for that resource to every microVM worker pod. So a microVM pool on a cluster without KVM does not fail loudly. Its pods stay Pending.

The script that creates the kind cluster tells you which case you are in. On the Apple silicon laptop under Docker Desktop it printed this:

outputthe KVM probe on the laptopFull log
Probing for /dev/kvm in the Docker environment...
/dev/kvm not available: micro-VM support disabled (gVisor still works).

On the x86 server guest, which has nested KVM, the same script printed the other answer:

outputthe KVM probe on the x86 server guestFull log
Probing for /dev/kvm in the Docker environment...
/dev/kvm found: micro-VM (kata + cloud-hypervisor) support will be enabled.

On that guest the install needed no further step for microvm. The kind script had passed /dev/kvm to the node, and the installer had created the SandboxConfig.

So on a Mac under Docker Desktop you have the gvisor class only. On a cloud VM, the instance type must allow nested virtualization. Check the node type before you plan for microvm.

The installer prepares both classes by default. It creates one SandboxConfig for each class and loads the guest kernel and the other microVM files into the object store. Pass --sandbox-classes=gvisor to skip the microVM part.

How the limits apply

The ActorTemplate declares one CPU limit and one memory limit for the actor. The two classes turn them into different things.

CPU. A gVisor actor with a limit of 500 or 1000 millicores sees 2 CPUs, and the node kernel slows it to its quota. A microVM actor with 500 millicores starts with 1 virtual CPU and gets half a CPU of node time. A program that sizes its thread pool from the CPU count behaves differently on each class.

Memory. On gvisor the limit is the memory that the sandbox can use. On microvm the limit is the RAM of the guest, and the virtual machine monitor needs more memory on top. The worker counts that overhead against its capacity, so the same pool holds fewer microVM actors than the limits alone suggest. In our run the overhead was 7.6 MiB for an actor with a limit of 456 MiB.

Containers. An actor can have more than one container. On gvisor one Sentry process runs all of them, and a limit on one container has no effect. On microvm each container has its own cgroup in the guest. A container that passes its memory limit is killed alone, and the other containers of the actor continue.

That last behaviour has a cost that others reported. In issue 3615, a container in a microVM actor was killed for memory, and the actor stayed ACTOR_STATE_RUNNING with no log line. Actor and its seven states shows the same silence for a process that exits on gvisor, which we did measure.

A snapshot pins the class

The class is not only a property of the pool. The first snapshot of an actor records it, and every later restore of that actor needs a worker of the same class. This is the snapshot record of our counter actor after a synchronous suspend.

yamlthe sandbox runtime in a snapshot recordFull log
    fidelity: SNAPSHOT_FIDELITY_MEMORY
    generation: 3
    locality: kind-control-plane
    owner: SNAPSHOT_OWNER_ACTOR
    sandboxRuntime:
      sandboxClass: gvisor
      version:
        attributes:
        - key: architecture
          value: amd64
        - key: cpu_features
          value: 8c0000002e500800008003f70000000f00400004219c01ab178bfbfffff83203
        - key: sandbox_bundle_hash
          value: sha256:88e9809119d7fa2204897a60f8bb155add102018b4b4947e7876c9bc447312fa
Lines from one entry of status.snapshots. The lines before fidelity and after the attributes are cut.

A microVM snapshot records different attributes. This is the same part of the record for the counter actor on a microVM pool.

yamlthe sandbox runtime in a microVM snapshot recordFull log
    fidelity: SNAPSHOT_FIDELITY_MEMORY
    generation: 5
    locality: kind-control-plane
    owner: SNAPSHOT_OWNER_ACTOR
    sandboxRuntime:
      sandboxClass: microvm
      version:
        attributes:
        - key: architecture
          value: amd64
        - key: cpu_model
          value: "23:49"
        - key: cpu_vendor
          value: AuthenticAMD
        - key: sandbox_bundle_hash
          value: sha256:1d40aaaf66896731f227d4cfe129ab724721b592ff89c73df603e8e2372a67b8
Lines from one entry of status.snapshots. The lines before fidelity and after the attributes are cut.

A gVisor snapshot names the CPU features as a bit string. A microVM snapshot names the CPU vendor and one CPU model. Three consequences follow from those records:

  • You cannot move an actor to the other class. A template that selects pools of both classes still gives each actor one class, the class of its golden snapshot.
  • A memory snapshot is tied to more than the class. It records the CPU architecture, the CPU features or the CPU model, and the exact sandbox build. A worker restores memory only when its runtime is compatible.
  • The result of a mismatch is a cold start. When no compatible worker has room, Substrate takes a snapshot from the object store and restores only its files. It uses another worker of the same class. The processes start again, and the actor loses what it held in memory.

The third point is the one to plan for. A pool that mixes node types with different CPUs, or a rollout of a new sandbox build, can turn a resume into a cold start. Placement shows where that decision is made.

What others reported about parity

The two classes do not behave the same at v1.0.0, and the project tracks the gaps in issue 3226. These reports come from the issue tracker. We ran none of them.

ReportClassWhat was seen
Issue 3615microvma container killed for memory leaves the actor RUNNING, with no log
Issue 3720microvmfile writes count against the memory limit, and an actor that wrote 2 GB was killed
Issue 3801microvmrestore runs under the CPU limit of the actor, so a small limit slows the resume
Issue 3153microvmevery small file operation crosses from the guest to the node, which slows work with many files
Issue 3408gvisorat about 6,000 actors on one node, sandboxes died while their actors stayed RUNNING

The two classes, measured

We ran the test actor of SuspendActor and ResumeActor on a microVM pool. The machine, the node, and the actor image were the ones behind the gVisor numbers. The actor holds a fixed amount of random memory.

Fig. 4.4resume time against memory held, for both sandbox classeschart
Resume time against memory held, for both sandbox classes Seconds for one request to a suspended actor against the MiB of random memory it holds, on the x86 server guest, from the snapshot on the node. Every one of the 60 resumes is a small dot, ten for each size and class, and a line joins the medians. The microvm class has a median of 0.33 s at 0 MiB, 0.49 s at 200 MiB, 0.80 s at 500 MiB. The gvisor class has a median of 0.22 s at 0 MiB, 0.47 s at 200 MiB, 1.07 s at 500 MiB. The microvm line starts 0.10 s higher and rises about half as fast, so the two lines cross a little above 200 MiB. 0.0 0.2 0.4 0.6 0.8 1.0 1.2 1.4 0 100 200 300 400 500 memory the actor holds, MiB seconds from request to reply, at the client gvisor 1.07 s median of 10 about 1.7 ms per MiB microvm 0.80 s median of 10 about 1.0 ms per MiB microvm 0.33 s gvisor 0.22 s at 200 MiB: 0.49 s and 0.47 s the medians cross above this size one resume median of one size
At 0 MiB a microVM resume took 0.33 seconds against 0.22 for gVisor, and at 500 MiB it took 0.80 against 1.07. Each dot is one resume from the snapshot on the node, and each line joins the medians of one class. Violet is the microvm class, and the dashed grey line is the gvisor class. Measured on the x86 server guest.
Memory heldPhasemicrovmgvisor
0 MiBfirst call of a new actor0.45 s0.23 s
0 MiBresume from the node0.33 s0.22 s
0 MiBsuspend, asynchronous0.39 s0.34 s
0 MiBsuspend, synchronous0.72 s0.40 s
200 MiBfirst call of a new actor0.79 s0.62 s
200 MiBresume from the node0.49 s0.47 s
200 MiBsuspend, asynchronous0.80 s0.79 s
200 MiBsuspend, synchronous1.89 s2.02 s
500 MiBfirst call of a new actor1.39 s5.76 s
500 MiBresume from the node0.80 s1.07 s
500 MiBsuspend, asynchronous1.17 s1.17 s
500 MiBsuspend, synchronous3.35 s3.59 s

We measured both columns on the x86 server guest (24 vCPU, 47 GB, nested KVM), with one kind node and the Envoy dataplane. Each cell is a median. Each size had 5 first calls, 10 resumes, 10 asynchronous suspends, and 5 synchronous suspends. The first call and the resume are timed at the client, through kubectl ate proxy. All 90 requests of the microVM run returned 200. A request to a running actor took 0.065 seconds on microvm and 0.064 seconds on gvisor. Raw runs

The table has four results:

  • A small actor pays a fixed cost on microvm. At 0 MiB the resume was 0.10 seconds slower and the first call was 0.22 seconds slower. The restore starts a guest with its own kernel before the actor runs.
  • Memory costs less on microvm. From 0 to 500 MiB, a resume grew by about 1 ms for each MiB on microvm and by about 2 ms on gvisor. The medians were 0.02 seconds apart at 200 MiB. Above that size microvm was the faster class.
  • A large new actor starts much faster on microvm. At 500 MiB the five first calls took 1.26 to 2.96 seconds on microvm and 1.86 to 6.76 seconds on gvisor.
  • An asynchronous suspend costs the same. The medians differ by 0.06 seconds or less at every size. A synchronous suspend of a small actor is slower on microvm, because its snapshot is larger.

What one actor costs on the node

Itemmicrovmgvisor
Processes on the node for one actorone cloud-hypervisor and two virtiofsdone runsc-sandbox and one runsc-gofer
Resident memory with 200 MiB held280 MiB237 MiB and 11 MiB
Snapshot on the node, 0 MiB held58.6 MiB1.3 MiB
Snapshot on the node, 200 MiB held264.0 MiB201.9 MiB
Snapshot on the node, 500 MiB held569.7 MiB502.2 MiB
Files of a memory snapshotmemory-ranges, state.json, config.json, one tar archive for each containerpages.img, checkpoint.img, pages_meta.img, a rootfs directory
Memory booked on the worker463.6 MiB for a limit of 456 MiB250 MiB for a limit of 250 MiB

We measured these values on the same x86 server guest, with one sample for each cell. Resident memory is the value that ps printed on the node. The snapshot size is the disk use of the snapshot directory of one actor. The gVisor booking is from the pool of the second x86 server guest. Raw runs

A microVM snapshot holds the whole guest, with its kernel and its agent. It was 59 to 70 MiB larger than the memory that the actor held. Add that amount for each actor when you size the node disk and the bucket in sizing a pool. The file memory-ranges is sparse. Its listed size is the RAM of the guest, and its disk use is what the guest touched.

The clock inside the actor

The test actor reports the age of its process in every reply. The two classes count that age differently.

We measured three suspend and resume cycles of the 0 MiB actor on each class, on the same guest. One cycle took 1.6 to 1.8 seconds. On microvm the age grew by 1.8 or 1.9 seconds in each cycle: 28.4, 30.3, and 32.1 seconds. On gvisor it grew by 0.2 or 0.3 seconds in each cycle: 0.5, 0.8, and 1.0 seconds. Raw runs

So a microVM actor sees the time that passed while it was suspended. A gVisor actor sees only the time that it ran. A timeout, a cache lifetime, or a token check inside the actor gives a different result on each class. We read the elapsed-time clock of the process only, and we did not compare the calendar time.

What stays untested

All of these numbers come from one x86 machine with one CPU model. We did not run microvm in these cases:

  • on arm64
  • with the agentgateway dataplane
  • with a resume from the object store
  • with more than 500 MiB held
  • on a pool whose nodes have different CPU models
  • with the loss of a node

The reports from the issue tracker in the table above also stay reports. We ran none of them.

Our position

Choose by the memory that your actors hold. With small actors, start with gvisor. It runs on every node, it resumed a small actor 0.10 seconds faster, and its snapshot is as large as the memory and no larger. It is also the class that kagent installs.

Choose microvm in three cases. A reviewer requires hardware virtualization between tenants. You need a limit for each container. Or your actors hold more than about 200 MiB. In the third case microvm resumed as fast or faster on our machine, and a new 500 MiB actor started four times faster. You pay with a node type that has KVM and with 59 to 70 MiB of extra snapshot for each actor.

Then test your own workload. The reports above say that the two classes differ in the cases that hurt: memory pressure and many small files.

Neither class makes the worker pod a boundary. Both worker types run as root with SYS_ADMIN and NET_ADMIN, and the threat model of Substrate rates the two classes as equal.

Placement: which worker gets the actor

ate-api-server picks a worker at every resume: it filters the workers, takes two at random, and binds the actor to the less loaded one.

Your actor ran on one worker pod this morning and on another one after lunch. Or a request fails with no worker has room for the actor while kubectl ate get workers shows a worker with free CPU. Both cases have the same cause. Substrate places an actor again each time it resumes, and the rules are not the rules of the Kubernetes scheduler.

When you finish this section, you can list the filters that a worker must pass and predict where an actor resumes. You can also explain a no worker has room error on a pool that is not full.

Most of our runs had one node. For this section we also built a cluster with one control plane node and two worker nodes, and we stopped one worker node four times. One result is against what the code suggested, and the section says where.

Two schedulers, two jobs

Kubernetes places worker pods on nodes, once, when the pool starts. It never sees an actor.

Substrate places actors on workers. The scheduler is a function inside ate-api-server. It runs in the ResumeActor call, and it reads the workers from memory and from Postgres, not from the Kubernetes API. That is why placement continued in our test with the Kubernetes API server down, as when a dependency is down shows.

A new actor has no worker. CreateActor writes a row with the state ACTOR_STATE_SUSPENDED, and placement waits for the first request. A suspend releases the worker again.

Placement: the choice of one worker for one actor, made at each resume.

The filters

Fig. 4.5how ate-api-server chooses a worker for one resumeflow
How ate-api-server chooses a worker for one resume A flow from ResumeActor to a bound worker. All workers pass five filters: registered and WORKER_STATE_ACTIVE, the sandbox class of the snapshot, the node that holds a local snapshot, a runtime that can restore a memory snapshot, and the labels of the selectors. Workers with room for the limits of the actor remain. With no candidate the result is the error no worker has room for the actor. With candidates, two are sampled at random and the less loaded one is bound in one Postgres transaction. ResumeActor actor is SUSPENDED SIX CHECKS FOR EACH WORKER 1 registered and WORKER_STATE_ACTIVE 2 sandbox class of the snapshot 3 node of a local snapshot, if one exists 4 runtime that can restore the memory 5 labels: workerSelector of template and actor 6 room for the limits of the actor no candidate no worker has room for the actor the router parks the request two at random the less loaded candidate wins bind one transaction in Postgres no worker passes the workers that pass ACTOR_STATE_RESUMING
A worker must pass six checks before it is a candidate, and the scheduler then takes the less loaded of two random candidates. Read from the top left. Each bar is one check that a worker must pass. Rose is the result with no candidate. Teal is the write to Postgres that binds the actor.

A worker is a candidate when all of these are true:

  1. It is registered, and its state is WORKER_STATE_ACTIVE.
  2. Its sandbox class is the class of the snapshot that the actor resumes from.
  3. It is on the node that holds the local snapshot of the actor, if a local snapshot exists.
  4. Its runtime can restore the memory snapshot, if the snapshot has memory.
  5. The labels of its pool match the workerSelector of the template and of the actor.
  6. It has room: one free actor place, and free CPU and memory for the booking of the actor.

With no candidate, the call fails with no worker has room for the actor. The text is the same whichever filter removed the last worker. So the error does not always mean that the pool is full. It can mean that the only workers with room are of the wrong class or without the right label.

With one candidate, that worker gets the actor. With more, the scheduler takes two at random and keeps the less loaded one. The load of a worker is its fullest dimension: actor places, CPU, or memory.

The bind is one Postgres transaction. It locks the row of the worker, checks the room again, and writes the assignment. Two resumes that compete for the last place cannot both win. The loser tries again with another worker.

What our runs showed

We created four actors on a pool of two workers and sent each one a request, one after the other.

outputfour actors on two workersFull log
ATESPACE   NAME    TEMPLATE           STATE                   WORKER POD                       WORKER IPS    VERSION   AGE
lab-c      b0-1    lab-c/ballast-0    ACTOR_STATE_RUNNING     lab-c/ballast-65cf95d875-bzgjd   10.244.0.26   43        69s
lab-c      b0-2    lab-c/ballast-0    ACTOR_STATE_RUNNING     lab-c/ballast-65cf95d875-f65dl   10.244.0.27   3         68s
lab-c      b0-3    lab-c/ballast-0    ACTOR_STATE_RUNNING     lab-c/ballast-65cf95d875-bzgjd   10.244.0.26   3         66s
lab-c      b0-4    lab-c/ballast-0    ACTOR_STATE_RUNNING     lab-c/ballast-65cf95d875-f65dl   10.244.0.27   3         65s
lab-c      b0-5    lab-c/ballast-0    ACTOR_STATE_SUSPENDED   <none>                                         1         63s
The header and the first five rows. The suspended actors below them are cut.

The actors alternate between the two pods. With two candidates, the scheduler always compares both, and the emptier one wins. The fifth actor found no candidate and stayed suspended.

The parking run shows the other half of the rule. Actor p3 waited for a worker. When we suspended p1, p3 resumed on the pod that p1 had used. An actor has no home worker. It goes wherever a place is free at that moment.

Across two nodes

The scheduler compares workers, not nodes. On the cluster with two worker nodes, the pool had four workers, two on each node. We created 20 actors, sent each one a request, and left all of them running.

outputwhere 20 actors went on two nodes and four workersFull log
node multi-worker 10
node multi-worker2 10
worker lab-n/ballast-5d6cf97747-xlw7d 6
worker lab-n/ballast-5d6cf97747-dpjlc 5
worker lab-n/ballast-5d6cf97747-5cmpq 5
worker lab-n/ballast-5d6cf97747-67bkm 4

We measured this spread on the second x86 server guest (16 vCPU, 23 GB), with one run of 20 actors on gVisor. Ten actors went to each node, and the four workers got 6, 5, 5, and 4. One create and one resume through the CLI took 0.95 to 1.19 seconds, with a median of 1.00 seconds. Raw runs

The node of the last suspend

Filter 3 is the one that surprises people. After an asynchronous suspend, the snapshot is on the disk of one node, and the actor remembers that node.

yamlan actor that remembers its nodeFull log
  assignedNode: kind-control-plane
  lastAssignedGeneration: 2
Two lines of the status. The rest of the object is cut.

While assignedNode is set, only workers on that node are candidates. The reason is speed: a resume from the local disk skips the download from the object store. In our runs the node kept the actor for 30 minutes after the suspend, the default of the atelet flag --snapshot-detach-delay. After that the field was empty and any node could take the actor. Snapshot fidelity, and where a snapshot lives has the measured timeline.

On the cluster with two worker nodes, the rule held in every resume.

We measured one actor on the second x86 server guest (16 vCPU, 23 GB), with five asynchronous suspends and one request after each. All 5 resumes went to the node of the suspend. The worker pod changed between the two workers of that node. Then we ran kubectl cordon on the node. The next 2 resumes still went to that node, in 0.41 and 0.29 seconds. Raw runs

outputtwo resumes on a cordoned nodeFull log
2026-11-12T12:02:26.555Z	cordon	multi-worker2
2026-11-12T12:02:26.986Z	resume-call-while-cordoned	200	0.408920	count=8 mb=50 sum=6613 process_age_s=8.2
2026-11-12T12:02:27.433Z	resumed-on	multi-worker2 pod lab-n/ballast-5d6cf97747-xlw7d
2026-11-12T12:02:28.977Z	suspended-sync	ACTOR_STATE_SUSPENDED	multi-worker2	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen8:multi-worker2
2026-11-12T12:02:29.292Z	resume-call-while-cordoned	200	0.293103	count=9 mb=50 sum=6613 process_age_s=9.0
2026-11-12T12:02:29.727Z	resumed-on	multi-worker2 pod lab-n/ballast-5d6cf97747-xlw7d
Six lines of the trace, from the cordon to the second resume.

A cordon is a Kubernetes mark for new pods, and the Substrate scheduler does not read it. So a cordon does not keep a suspended actor away from its node. The fourth line shows one more fact. After a synchronous suspend the snapshot is complete, and the actor still names the node.

The rule has a cost when the node is full while another node has free workers. The code handles that case in the same resume call. ate-api-server asks atelet on that node to upload the snapshot and delete the local copy, and then it tries the other nodes.

We measured that path once, on the second x86 server guest (16 vCPU, 23 GB), with both worker nodes up. We filled all four workers, and a request to the suspended actor got 503 after 5.05 s, in 2 of 2 requests. The body was no worker has room for the actor, and the ate-api-server log names the step DetachFromFullNode. We then freed one worker on the other node, which did not hold the snapshot. The next request returned 200 in 0.61 s on that node, and the count of the actor continued. Raw runs

A drain takes the same path. After kubectl drain, the node had no worker, and its atelet still ran. Three suspended actors resumed on the other node at their first request, in 0.49 to 1.19 s. When a dependency is down has the five drain cases.

When the node stops

Our reading of the code said that an actor with a durable snapshot forgets a node that is down and resumes on another node. Our runs did not show that. We stopped one worker node four times, with the actor in a different condition each time. The other node had two active workers with room in every run.

Fig. 4.6a suspended actor after its node stopstimeline
A suspended actor after its node stops A timeline of 476 seconds after the node multi-worker2 was stopped, on a cluster with two worker nodes. The actor u1 was suspended on that node with its snapshot complete in the object store. The two workers of the node stay listed as active for 43 seconds and are then marked unavailable. Their pods are evicted after 5 min 43 s. The actor is in the resuming state on the stopped node from the first request, and 43 requests fail in that time. At 5 min 44 s the actor is crashed, and the next 57 requests fail at once. The actor never moves to the other node. node multi-worker2 docker stop, the node is gone after 10 s started again at 7 min 56 s its 2 workers in the API list WORKER_STATE_UNAVAILABLE after 43 s pods evicted, 5 min 43 s ACTIVE actor u1 suspended, sync RESUMING on the stopped node, 5 min 33 s ACTOR_STATE_CRASHED at 5 min 44 s requests one bar for each 2 times 504 after 10 s, 41 times 503 actor lab-n/u1 unavailable 57 times 503 at once AssignWorker prerequisite not met 0 1 min 2 min 3 min 4 min 5 min 6 min 7 min 8 min Time from the stop command. The other node had two active workers with room.
A suspended actor with a complete snapshot stayed in RESUMING for 5 min 33 s from its first request, on a stopped node, then became CRASHED. Read left to right from the stop command. The tracks are the node, its two workers as the API lists them, the state of the actor u1, and one bar for each request. Amber is the step state and rose is failure.

First, what Substrate knows about the node.

We measured the detection on the second x86 server guest (16 vCPU, 23 GB), in 4 of 4 stops. The workers of the stopped node were listed WORKER_STATE_ACTIVE for 43 to 51 seconds after the stop command. Then they were marked WORKER_STATE_UNAVAILABLE, at the time that Kubernetes reported the node NotReady. Kubernetes evicted the worker pods 5 minutes later, and the Deployment created two new pods on the other node. Raw runs

jsonhow ate-controller learns that a worker is goneFull log
{"time":"2026-11-12T12:03:19.231654833Z","level":"INFO","msg":"Syncer: marking worker unavailable (pod not Ready)","worker":"090b9723-a53d-4e0c-a139-741942971a78","pod":"lab-n/ballast-5d6cf97747-67bkm"}
One log line, 43 seconds after the first stop command.

The reason in that line is a pod that is not Ready. Substrate learned of the dead node from Kubernetes, and in our runs nothing in Substrate reported it sooner. For those 43 to 51 seconds, the scheduler can give a new resume to a worker on a node that is gone.

Second, what happened to the actor in each of the four runs.

The actor when the node stoppedWhat the caller sawThe actor state
Suspended with --mode sync, snapshot COMPLETED, then a request every few seconds504 after 10 s, when Envoy stopped its wait for the router, then 503 actor lab-n/u1 unavailable after 5 to 8 s. From 5 min 44 s after the stop command, 503 at once with AssignWorker prerequisite not metRESUMING on the stopped node from the first request, then CRASHED
The same, with a revert 31 s after the stop command and before any requestThe revert returned SUSPENDED with no node. The first request then bound the actor to a worker of the stopped node. It got 504 after 10 s, when Envoy stopped its wait, and each of the next 19 requests got 503RESUMING on the stopped node for the 2.5 min that we probed
Suspended in the default mode, snapshot PENDING, requests for 2 min and then none504, then 503. A revert after the first request failed: another operation is in progress for this actorStill RESUMING 10 min 42 s after the stop command, with no request in the last 8 min 43 s. A second revert failed: not in a revertable state. The actor was CRASHED at the next request, 0.6 s after that second attempt. The trace cannot say which of the two caused the change
RUNNING on the node503 for each of 150 requests: upstream connect error or disconnect/reset before headers. A revert failed: while calling atelet TerminateRUNNING for the 10 min that we probed, then REVERTING after the failed revert
outputthe first run, from the suspend to the crashFull log
2026-11-12T12:02:36.103Z	suspended-sync	ACTOR_STATE_SUSPENDED	multi-worker2	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
2026-11-12T12:02:36.563Z	node-stop	docker stop multi-worker2
2026-11-12T12:02:46.908Z	node-stopped	multi-worker2
2026-11-12T12:02:57.202Z	probe	504	10.055616		|	ACTOR_STATE_RESUMING	multi-worker2	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
2026-11-12T12:03:07.214Z	probe	503	7.777073	actor lab-n/u1 unavailable	|	ACTOR_STATE_RESUMING	multi-worker2	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
…
2026-11-12T12:08:14.892Z	probe	503	5.051588	actor lab-n/u1 unavailable	|	ACTOR_STATE_RESUMING	multi-worker2	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
2026-11-12T12:08:20.312Z	probe	503	3.190662	actor lab-n/u1 unavailable: AssignWorker prerequisite not met for Actor: lab-n/u1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED)	|	ACTOR_STATE_CRASHED	multi-worker2	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
Seven lines of the trace. The 40 requests between the second request and the last request in the state RESUMING are cut.

Our client allowed 12 s for each of these requests. So the 504 at 10.06 s came from the dataplane, at the limit that section 4.4 describes. We first read it as our own timeout.

A suspended actor did not move to the other node. This is the result that is against our reading of the code. In the first run the snapshot was complete in the object store, and the other node had two free workers. The actor still stayed bound to the stopped node. It was in ACTOR_STATE_RESUMING for 5 min 33 s from its first request, and it was ACTOR_STATE_CRASHED 5 min 44 s after the stop command. Substrate v1.0.0 did not move an actor off a dead node by itself in any of our runs.

A revert did not free the actor either, in 4 of 4 attempts in three runs. In the second run it came too early. The workers of the stopped node were still listed as active, and the next request chose one of them. In the third run the resume that was in progress blocked both attempts. In the fourth run the revert needed atelet on the stopped node, and the actor stayed in ACTOR_STATE_REVERTING.

Those four runs left one question: does a revert help when you send it after the workers are marked unavailable? A second set of 13 node-stop cases answered it. Yes, for one kind of actor only.

We measured each case once, on a second x86 server guest (16 vCPU, 23 GB). The cluster had two worker nodes, the Envoy dataplane, and the default timers. In the first case, an actor was suspended with --mode sync and got no request after the stop. The workers of the node showed WORKER_STATE_UNAVAILABLE at 45 s. One revert then took 7.1 s and returned the actor with no node. The next request resumed it on the other node in 0.56 s, and the count continued from the snapshot. Raw runs

outputthe one revert that worked while the node was downFull log
2026-11-12T12:53:34.330Z	t=0.7	node-stopped	multi-worker2
2026-11-12T12:54:18.228Z	t=44.6	workers-of-stopped-node-not-active	      2 WORKER_STATE_ACTIVE       2 WORKER_STATE_UNAVAILABLE 
2026-11-12T12:54:25.821Z	t=52.1	revert	7.1 s | ATESPACE   NAME   TEMPLATE           STATE                   WORKER POD   WORKER IPS   VERSION   AGE lab-n      a1     lab-n/ballast-50   ACTOR_STATE_SUSPENDED   <none>                    8         56s 
2026-11-12T12:54:26.821Z	t=53.1	first-request	200	0.560088	count=4 mb=50 sum=6613 process_age_s=4.9	|	ACTOR_STATE_RUNNING	multi-worker	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:-
Four lines of the trace. The columns are the time, the seconds from the stop command, the event, and its detail. The actor row that the revert printed is cut.

The condition is narrow. The same kind of actor, reverted 10 s after the stop command, went to ACTOR_STATE_REVERTING and stayed there until the node was back. An actor that had got a request was refused 19 of 19 times while it was ACTOR_STATE_RESUMING. An actor with a PENDING snapshot, and a RUNNING actor, went to ACTOR_STATE_REVERTING at the first revert. In all of these cases the revert calls atelet on the stopped node, and that call fails.

One path worked in every state: a tag that exists before the node stops. A new actor from the tag served its first request on the other node 1.2 s after the create command, in 3 of 3 rescues. Section 8.2 has the runbook for each case, and figure 8.2d puts it on one page. Without a tag, treat a stopped node as an outage for every actor that got a request.

A RUNNING actor on a dead node is the worst case to see. Its state did not change in 10 minutes, so a list of actors shows nothing wrong. CRASHED and RevertActor has the error texts for each case.

When the node returned, it was Ready within seconds. It had none of the four pool pods in three of the four returns, because the new pods ran on the other node. Kubernetes does not move them back, and no new actor went to the returned node for more than 3 minutes. After we deleted two pool pods by hand, the first actor went to the returned node 23 to 35 seconds after the node start.

The second set of cases gave the same split, with more returns. After an outage shorter than the eviction time, the node kept its pool pods. The first new actor went to it 23 to 45 seconds after the start, in five returns. After a longer outage the node had no pool pod, in seven of seven returns. No new actor went to it in the 216 to 223 seconds that we watched. Substrate does not rebalance a pool.

We measured the case where the other node has no room, in two runs on the same guest. The revert was accepted, and each request got 503 after 5 s with no worker has room for the actor, in 8 of 8 requests. The actor stayed ACTOR_STATE_SUSPENDED. It answered 1.6 s after we freed a worker in one run, and 1.7 s after we scaled the pool in the other. When a dependency is down has both runs. Raw runs

We did not test a pool with spare workers on a third node, or a node loss with the microVM class. We also did not test a node that is alive and unreachable.

After a node restart

A restart of the node removes every worker at one time. We did that twice on the single node of the x86 server guest. The first run sent one request after the pods were back. The second run tried one resume every 2 to 3 seconds and listed the workers each time.

We measured the first recovery on the x86 server guest (24 vCPU, 47 GB, nested KVM), one run. The Kubernetes API answered again after 8 seconds, and all pods were running after 147 seconds. A request for a suspended actor then returned 503 with no worker has room for the actor. The same request returned 200 about 80 seconds later. Raw runs

The second run shows why.

Seconds from the restart commandWhat we saw
0 to 10The restart command runs.
10 to 157kubectl ate fails with no ready api pods in ate-system. Kubernetes reports the node Ready from 18 s.
160ate-api-server answers for the first time. It lists all 11 workers as WORKER_STATE_ACTIVE. A resume fails with while calling atelet Restore.
163 to 171Each worker from before the restart is marked WORKER_STATE_UNAVAILABLE. A resume fails, because the worker assigned to the actor is unavailable.
174The first resume that works.
178 to 191The workers return to WORKER_STATE_ACTIVE one by one. At 191 s, 11 of 11 are active.

We measured this timeline on the x86 server guest (24 vCPU, 47 GB, nested KVM), with one restart and one suspended actor. Each poll made one resume attempt. Raw runs

The mechanism has three steps. When the control plane returns, it first serves the worker list that it had before the restart. Then it marks each of those workers unavailable. Each worker becomes active again when it registers through atelet, as WorkerPool describes. Between the second and the third step, no worker of the pool is a candidate.

A resume in that window fails for lack of room or for an unavailable worker. The window was 10 to 30 seconds in this run. In the first run our one retry came 80 seconds later, so the window looked longer than it was.

The long part of the recovery is the control plane, not the workers. ate-api-server needed 160 of the 191 seconds.

Plan for this after a node restart: no call works until ate-api-server answers, and resumes can fail for about 30 seconds more. Do not use a check that all pods are Running as the signal. Kubernetes showed the old pod status at first: 0 pods not running at 10 seconds, and 21 at 18 seconds.

An actor that was running on a node that restarts is ACTOR_STATE_CRASHED, and CRASHED and RevertActor covers it. A node that stays down is different, as the table above shows. We did not repeat the restart on a cluster where the control plane and the workers are on different nodes.

Steer placement with labels

You have two tools, and both are labels:

  • Pool labels and selectors. Put a label on the WorkerPool object, and select it in the template with workerSelector.matchLabels. An actor can add its own selector, and both must match.
  • Node rules for the pool. spec.template.nodeSelector, tolerations and nodeAffinity of the pool decide which nodes the worker pods use. They act on Kubernetes, before any actor exists.

There is no way to pin one actor to one worker, and no priority between actors. A template can select pools with different CPU types, and that is unwise. A memory snapshot from one type cannot always restore on the other.

Request parking

When no worker has room, the router holds a request for up to 5 seconds and retries the resume before it answers 503.

Twenty users open a session in the same second, and your pool has room for sixteen actors. Four requests have no worker. You need to know if those four users see an error at once, a delay, or an error after a delay. The answer is all three, and the router decides which.

When you finish this section, you can predict what a client sees when a pool is full. You can also tell the two capacity 503 texts apart and choose a client timeout.

What the router does when a pool is full

Every request to an actor passes through atenet-router, and the router calls ResumeActor for it. If the actor is suspended, ate-api-server must find a worker with room. When no worker has room, the call fails with the gRPC code ResourceExhausted and the text no worker has room for the actor.

Parked request: a request that the router holds open while it retries the resume of the actor.

The router does not return that failure at once. It parks the request and tries again. Five rules decide what happens next:

  • Retry. The first retry comes after 100 ms. Each later delay is 1.1 times longer, with a small random part.
  • Budget. The router stops retrying after 5 seconds, the value of --parked-request-budget. Then the client gets the 503.
  • One budget for each actor. Requests for the same actor share one resume call and one budget clock. A request that joins late can fail after less than 5 seconds.
  • Limit. At most 1,024 requests can be parked at one time, the value of --parked-request-max. A request takes a place only when its first resume attempt has failed and it starts to wait. When no place is free, the router sheds the request after that one attempt, with a 503. The body is actor "<atespace/name>" unavailable: router at capacity.
  • No cancel. A resume that has started when the budget ends is not cancelled. The router waits for its result, so a request can be served after more than 5 seconds.

Set --parked-request-max=0 to turn parking off. Then a full pool returns the 503 at once.

The install sets none of these flags, so the defaults apply. The arguments of our router show it.

outputthe router arguments in our installFull log
atenet-router router --mode=ingress --namespace=ate-system … --drain-delay=13s --ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client --preview=
The arguments between the mode and the drain delay are cut. No flag that starts with --parked-request is present.

What the router parks, and what it does not

Parking is for conditions that can end without help. The router returns every other error at once.

Result of the resumeWhat the router doesWhat the client sees
the actor is runningroutes the requestthe answer of the actor
no worker has roomparks and retriesthe answer, or 503 after the budget
ate-api-server is unavailableparks and retriesthe answer, or 503 after the budget
another resume of the same actor is activeretriesthe answer
the actor is ACTOR_STATE_CRASHEDreturns the error503 at once
the actor does not existreturns the error404 at once

The third row matters during a rollout of ate-api-server. A short restart of the control plane becomes a delay for the client and not an error. When a dependency is down shows the limit of that protection.

The fifth row is why a 503 after 5 seconds and a 503 after 60 ms are different problems. A slow 503 with the text no worker has room means the pool is too small. A slow 503 with only the word unavailable means the router got no answer from ate-api-server. A fast 503 with the text AssignWorker prerequisite not met means the actor is crashed, and CRASHED and RevertActor has the fix.

One request, three outcomes

The parking demo of Substrate creates a pool of two workers with room for one actor each, and four actors named p1 to p4. We sent one request each to p1 and p2, so both workers were busy. Then we sent a request to p3 two times.

Fig. 4.7requests to a full pool against the 5 second parking budgettimeline
Requests to a full pool against the 5 second parking budget Four rows on a time axis from 0 to 6 seconds, with a dashed line at the 5 second parking budget. Two requests to a pool with a free worker return 200 in 0.32 and 0.36 seconds. 9 requests to a full pool each wait 5.05 to 5.06 seconds and return 503. One parked request returns 200 in 2.65 seconds, because a worker became free 2.37 seconds into its wait. In a 30 second load run, 90 of 92 requests were served, in 0.85 seconds on average and 4.27 seconds at the slowest. parking budget 5 s free worker p1 and p2, no wait 200 in 0.32 and 0.36 s full pool 9 requests, no worker 503 in 5.05 to 5.06 s full pool p3, then p1 suspends 200 in 2.65 s worker free at 2.37 s, answer 0.28 s later load run, 30 s 90 of 92 served slowest 4.27 s average 0.85 s 0 1 2 3 4 5 6 seconds from the start of the request to its answer
A parked request is served when a worker becomes free inside the 5 second budget, and it gets a 503 when none does. Each bar runs from the start of a request to its answer, and each dot is one request. Green is a 200 and rose is a 503. The violet mark is the moment when the suspend of p1 freed a worker. The dashed bar is the load run, with its average and its slowest served request.
outputp3 fails, then p3 is served after a waitFull log
2026-11-12T11:32:40.522Z	p3-no-free-worker	503	5.059628	actor ate-demo-parking/p3 unavailable: no worker has room for the actor
2026-11-12T11:32:45.600Z	p3-request-start	parked while pool is full
2026-11-12T11:32:47.970Z	suspend-p1-done	freed a worker 2 s after p3 request started
2026-11-12T11:32:45.603Z	p3-served-after-park	200	2.647469	preserved memory count: 1 | preserved file counter: -1 | preserved rootfs counter: 1
The header and the first two rows are cut. The columns are time, event, HTTP code, seconds, and body. The last row has the start time of its request, so it sorts before the suspend.

The first request to p3 found no worker for the whole budget. The second one started the same way. Two seconds into its wait we suspended p1, and that freed a worker. The next retry of the router found the worker, p3 resumed there, and the client got its answer.

We measured the wait on a full pool on the x86 server guest (24 vCPU, 47 GB, nested KVM, Envoy dataplane). Nine requests that found no worker each returned 503 after 5.05 to 5.06 seconds. The request that we freed a worker for returned 200 after 2.65 seconds. The suspend of p1 finished 2.37 seconds into that wait. The router then needed about 0.3 seconds to retry, resume the actor, and route the request. That last number comes from one run. Raw runs

The body of the slow 503 is always the same text, with the atespace and the name of the actor in it:

outputthe 503 of a full poolFull log
actor lab-c/b0-5 unavailable: no worker has room for the actor
One cell of the first failed row.

Thirty seconds with four actors on two workers

The demo has a load script. It sends requests to all four actors for 30 seconds and suspends each actor after its answer. Two workers serve four actors, so half of the requests must wait for a suspend.

outputthe result of the load scriptFull log
==> results
    total requests : 92
    200 OK         : 90
    503 unavailable: 2
    other          : 0
    200 latency    : avg 0.847s, slowest 4.269406s  <- parked requests sit here
The lines before the results are cut.

We measured this load run on the same x86 server guest, one run of 30 seconds, 92 requests. The router served 90 requests and returned 503 for 2. The served requests took 0.85 seconds on average, and the slowest took 4.27 seconds. A request to a free worker in the same pool took about 0.35 seconds. Raw runs

Read those numbers together. Parking turned a pool that was two times too small into a service that answered 98% of requests. It did that with latency. The average request took more than two times longer than a request to a free worker. The slowest took twelve times longer. Two requests still failed, in a demo that was built to show parking at its best.

A burst of resumes

The demo sends its requests one after another. A real burst arrives in one instant, so we tested that. The pool had two workers with room for 16 one-CPU actors. We suspended n actors of 50 MiB each and sent one request to each of them at the same instant, five rounds for each n.

Fig. 4.8resume time against the number of resumes at the same instantchart
Resume time against the number of resumes at the same instant Seconds for one request to a suspended 50 MiB actor against the number of such requests sent at the same instant, on the x86 server guest with gVisor and a pool with room for 16 actors. Every one of the 155 requests is a small dot, five rounds for each level, and a line joins the medians. 155 of 155 requests returned 200. The median is 0.23 s at 1, 0.23 s at 2, 0.26 s at 4, 0.29 s at 8, 0.37 s at 16. The slowest of the 80 requests at 16 at once took 0.45 s. A dashed line marks 0.5 s. 0.0 0.1 0.2 0.3 0.4 0.5 0.6 1 2 4 8 16 resumes at the same instant, one request for each suspended actor seconds from request to reply, at the client 0.23 s 5 of 5 0.23 s 10 of 10 0.26 s 20 of 20 0.29 s 40 of 40 0.37 s 80 of 80 16 at once 0.37 s median of 80 64 percent more than 1 slowest 0.45 s 0.5 s returned 200, of sent one request median of one level
Sixteen resumes at the same instant took 0.37 seconds each at the median, against 0.23 seconds for one resume alone. Each dot is one request to a suspended actor, and the line joins the medians of each level. The horizontal axis doubles at each step. The grey numbers are the requests that returned 200. The dashed line marks 0.5 seconds. Measured on the x86 server guest.
Resumes at the same instantRequestsReturned 200MedianSlowest
1550.23 s0.24 s
210100.23 s0.24 s
420200.26 s0.31 s
840400.29 s0.33 s
1680800.37 s0.45 s

We measured these 155 requests on the x86 server guest (24 vCPU, 47 GB, nested KVM), with gVisor and the Envoy dataplane. Each request went through its own kubectl ate proxy, and the time is at the client. All 155 returned 200. Sixteen resumes at once cost 64 percent more for each request than one resume alone. ate-api-server logged no error line during the bursts. Raw runs

So the control plane did not queue at 16 resumes at once. A burst that fits in the pool costs tenths of a second, and no request waits for the budget.

A burst that does not fit is split by the pool, with no request in between. We sent 32 requests at the same instant to 32 suspended actors, when the pool had 14 free places.

We measured that burst once, on the same guest. Fourteen requests returned 200, at a median of 0.37 seconds. The other 18 returned 503 after 5.11 to 5.15 seconds, with the text no worker has room for the actor. In a second burst of 32 we suspended 16 actors 1.5 seconds into the wait. Then 21 of 32 requests returned 200, the slowest after 2.69 seconds, and 11 returned 503. Raw runs

The pool had 14 free places and not 16 for a reason that section 3.5 explains. Two actors whose process had exited were still ACTOR_STATE_RUNNING, and each one held a CPU.

A resume that takes more than 10 seconds

The parking budget is not the only clock. Envoy waits a fixed time for the router to decide where a request goes. The code sets that time to the parking budget plus 5 seconds. With the default budget that is 10 seconds. A resume that takes longer gets a 504 from Envoy, and the resume is not cancelled.

We measured this on a second x86 server guest (16 vCPU, 23 GB), in the bucket runs of section 8.1. In 32 of 61 cycles the resume had to read a large snapshot from a far or slow bucket. In each of the 32, the first request returned 504 after 10.05 s with an empty body. The client sent the request again each second and sent no other command. Each of the 32 then returned 200 with the right count. In the slowest cycle the first 200 came after 213 s and 19 requests. Raw runs

The limit is on the decision of the router, and not on the whole request. In an earlier run, three requests returned 200 after 10.09 to 10.87 s.

A 504 after 10 s has a second cause, and the state of the actor separates the two. During a slow resume the actor reads ACTOR_STATE_RESUMING. After a database outage, one actor that read ACTOR_STATE_RUNNING returned the same 504 on every request. Section 8.2 has that case.

Our position

Parking hides a short shortage. It does not add capacity. A pool that parks requests all day is too small, and your users pay for that with seconds on every request.

Three things follow for your client and your alerts:

  • Client timeout. Allow at least 10 seconds for a request to an actor that can be suspended. The budget is 5 seconds, a resume that started late still completes, and the Envoy dataplane ends the wait 5 seconds after the budget.
  • Retry a 504 too. A 504 after 10 seconds on a request to a suspended actor means that the resume is still at work. Send the request again after a pause, and send no revert. A revert of an actor in ACTOR_STATE_RESUMING is refused, as section 8.2 shows.
  • Retry. Retry a 503 with the text no worker has room after a pause. Do not retry it in a tight loop, because each retry takes a place among the parked requests.
  • Alert. The router counts parked requests in the metric atenet.router.parking.active and requests over the limit in atenet.router.parking.rejected. Alert when the first one stays above zero.

All of this section is the Envoy dataplane, where the router process of Substrate handles each request. We did not see a 503 from a full pool on the agentgateway dataplane. The kagent run uses agentgateway, and it did fill its one worker: 16 actors ran, and 8 of 24 parallel turns waited and then completed. Sizing a pool has that run.

We did not test more than 1,024 parked requests. Our bursts into a full pool stopped at 32 requests, and each request had its own kubectl ate proxy. Later bursts of up to 300 went through the router ingress to free workers. No request got the 503 of a full pool. Sizing a pool has those.

The project site states 500+/s suspend and resume activations. One node of ours finished 32 to 39 resumes each second with up to 300 at one time. It served 600 of 600 at 300. Sizing a pool has the runs.

Sizing a pool

The limits that you declare decide how many actors run at one time, and disk decides how many suspended actors you can keep.

Someone asks how many agents your cluster can hold. The question has two answers, and they differ by orders of magnitude. One answer is the number of actors that run at the same moment. The other is the number of actors that exist and wait in a suspended state.

When you finish this section, you can compute both numbers for a pool and choose the limits of a worker. You can also name the resource that runs out first.

Running actors: the arithmetic of declared limits

A worker reports a capacity, and each running actor books a part of it. WorkerPool showed where the capacity comes from. The booking is the limit that the ActorTemplate declares, plus a memory overhead that the sandbox runtime reports. The scheduler never looks at what an actor uses.

The number of actors that one worker runs is the smallest of three values:

  • the CPU limit of the worker, divided by the CPU limit of the actor
  • the memory limit of the worker less 64 MiB, divided by the memory booking of the actor
  • 1,000, the default of --max-actors
Fig. 4.9running actors per worker follow the CPU that the worker reportscomparison
Running actors per worker follow the CPU that the worker reports Two columns. On the left, each of two workers with a limit of 2 CPUs reports 2000 millicores and holds two actors that declare 1 CPU each. A fifth actor has no room and its request returns 503 after 5.06 seconds. On the right, one kagent worker with no limits on a node with 16 vCPUs reports 16000 millicores. With 24 turns at the same time, 16 actors run and 8 wait. LIMITS SET: CPU 2, MEMORY 3GI NO LIMITS, NODE WITH 16 VCPUS worker bzgjd CPU 2000/2000 worker f65dl CPU 2000/2000 fifth actor b0-5 503 after 5.06 s capacity: 2 workers x 2 CPUs = 4 actors each actor declares 1000 millicores memory was not the limit: 512Mi of 3008Mi worker kagent-default CPU 16000/16000, 16 turns run 8 more turns wait for a free CPU 24 turns at once, all completed in 4.3 to 7.8 s in the kagent run running actor, 1 CPU waits refused
Two workers with a 2 CPU limit ran four one-CPU actors, and one worker with no limits ran sixteen on a node with 16 vCPUs. Each green square is a running actor that declares 1 CPU. The rose square is an actor that found no room. The amber squares are turns that waited. The two columns are two different test machines.

We measured the left column on the x86 server guest (24 vCPU, 47 GB, nested KVM). The pool had two workers with a limit of 2 CPUs and 3 GiB. Each actor declared 1 CPU and 256 MiB. Four actors ran, two on each worker. Eight requests that needed a fifth running actor all returned 503. CPU was the limit: each worker had booked 512Mi of 3008Mi. Raw runs

We measured the right column on a second x86 server guest (16 vCPU, 31 GB), in the kagent run. The kagent pool sets no limits, and each actor declares 1 CPU and 1 GiB. We started 24 turns at the same time, one run. The worker reached 16 running actors and 16000 of 16000 millicores, and the other 8 turns waited. All 24 turns completed, in 4.3 to 7.8 seconds. With 8 turns at the same time, all 8 completed in 2.5 to 3.6 seconds. Raw runs

The parallel turns chart in section 7.1 shows the time of each turn in both runs.

The second result looks like free capacity, and it is the opposite. That worker promised every CPU of the node to actors, and the control plane runs on the same node. Set limits, as WorkerPool explains.

The CPU value of a template is a booking for placement, and the worker also writes it into the sandbox as a CPU quota. That quota is a hard cap.

We measured a fixed amount of work under four limits on a second x86 server guest (16 vCPU, 23 GB). Each of eight rows had three runs. The actor used 0.25, 0.50, and 1.01 host cores at 250, 500, and 1000 millicores, with one thread or with four. At 2000 millicores, one thread used 1.12 cores and four threads used 1.96 to 2.01. The work took 27.1 to 27.6 seconds at 250 millicores and 4.9 to 5.4 seconds at 1000. Raw runs

WorkerPool has the table and the chart.

So a low value packs more actors on a worker, and each of them is that much slower when it is busy. Size the value from the CPU that one turn needs, not from the average. The actor still sees 2 CPUs at every limit, so set thread counts in the template yourself. We did not test several busy actors on one worker at the same time.

A burst of resumes inside the capacity of the pool is cheap.

We measured 16 resumes at the same instant on the x86 server guest (24 vCPU, 47 GB, nested KVM), 80 requests in five rounds. The median was 0.37 seconds, against 0.23 seconds for one resume alone, and all 80 returned 200. Request parking has the chart. Raw runs

A dead actor counts as a running one. An actor whose process has exited stays ACTOR_STATE_RUNNING, and it keeps its booking. Two such actors each held 1,000 millicores of a worker for the 20 minutes of our watch. A burst of 32 resumes then found 14 free places in a pool of 16. CRASHED and RevertActor shows how to find and free them.

Two reports by other people help you choose the CPU limit of an actor. We did not repeat either.

A contributor measured real CPU use on workers that were fully booked, in issue 3441. Seven actors with 1 CPU each used between 0.55 and 0.8 cores together, at the median. A generous limit therefore costs you most of a worker, because Substrate at v1.0.0 has no overcommit.

A user measured resume time against the CPU limit of the actor on a release candidate of v1.0.0, in issue 3431. The median was 0.46 seconds at 50 millicores, 0.23 seconds at 100, and about 0.15 seconds from 250 up. A limit below 250 millicores saves capacity and costs resume time.

Suspended actors: disk, in three places

A suspended actor books nothing on a worker. You can keep far more suspended actors than running ones. Each of them costs disk.

The footprint chart in section 3.4 shows these sizes as bars.

We measured disk use on the x86 server guest (24 vCPU, 47 GB, nested KVM) with 25 suspended actors, one sample. The actors held 8,750 MiB of memory in total. The node held 8,801 MiB of snapshots and the object store held 10,824 MiB. After we deleted the actors, the node held 1 MiB of snapshots and the object store held 2,064 MiB. A cache of 1,765 MiB stayed on the node in both samples. Raw runs

Three rules come out of those numbers:

  • A memory snapshot is as large as the memory that holds data. An actor with 200 MiB of random bytes made a snapshot of 201.9 MiB. Random bytes are the worst case. The same actor with 200 MiB of zero bytes made a snapshot of 4.0 MiB, and with half of each, 103.8 MiB. On the microVM class the snapshot also holds the guest, and it was 59 to 70 MiB larger, as sandbox classes shows.
  • You pay two times for 30 minutes. With the default timers, the node keeps its copy for 30 minutes after the suspend. The object store has a second copy after 5 minutes. Snapshot fidelity, and where a snapshot lives has the timeline.
  • Golden snapshots stay. The 2,064 MiB that remained belong to the five templates. Deleting actors does not free that space.

The disk that one node needs for snapshots is therefore the number of suspends in 30 minutes, times the snapshot size. A node that suspends 200 actors of 200 MiB each half hour needs 40 GiB for snapshots alone. The snapshot directory is a path on the node, and Kubernetes charges it to no pod.

On a pool that spans nodes, size each node by itself. A suspended actor resumes only on the node of its last suspend, for 30 minutes with the default timers. In our runs on two worker nodes, no actor moved off a node that stopped. When the node returned, Kubernetes left the new worker pods on the other node. Placement has those runs.

Many resumes at one time

A pool with free workers still has a limit on how fast it starts actors. We sent bursts of resumes to one node and timed each request. Figure 4.5b shows every request of two runs.

Fig. 4.10resumes at one time on one nodechart
Resumes at one time on one node: the time of each request, and the rate Two panels for bursts of 1 to 300 resumes at one time on one node with 24 vCPU, in two runs. The first run had 200 workers and three bursts of each size from 1 to 150. The second run had 300 workers and bursts of 150, 200 and 300. The slowest of 10 took 0.43 s. At 300 at one time the median was 7.3 to 7.4 s. From 50 upward the node finished 32 to 39 resumes each second. 4 requests of the second run got no answer in 90 s and are not drawn. one burst, its requests from first to last answer 0.5 s, from the project site first run: 200 workers, 1 or 2 connections second run: 300 workers, 1 connection each 0 0.5 2 4 6 8 10 1 10 50 100 150 150 200 300 seconds until the reply, each request 0 10 20 30 40 50 1 10 100 300 resumes finished each second 32 to 39 each second from 50 at one time resumes sent at one time, to actors that each have a snapshot on the node
One node finished 32 to 39 resumes each second from 50 to 300 at one time, so the wait grew with the size of the burst. In the left panel each line is one burst, with its requests in order from the first answer to the last. A line passes through 31 of them. The sizes on its horizontal axis are not to scale. The right panel has the rate of each burst against the burst size. The dashed line is the 0.5 s that the project site states. Four requests of the second run got no answer in 90 s and are not drawn.

We measured the first run on the x86 server guest (24 vCPU, 47 GB, nested KVM). It had one kind node and a pool of 200 gVisor workers. The actor was a Python counter with a 128 MiB limit, and each actor had its own snapshot on the node. One client process in a pod sent each burst to the router, over one or two HTTP/2 connections. Each burst size ran three times, and all 933 requests returned 200. Raw runs

Resumes at one timeMedian of each burstSlowest requestResumes each second
1, 30 times in a row0.36 s (0.33 to 0.40)0.40 snot a burst
100.38 to 0.40 s0.43 s23 to 24
501.16 to 1.34 s1.38 s36 to 38
1002.21 to 2.26 s2.73 s36 to 39
1503.16 to 3.42 s4.14 s36 to 38

The rate stops rising at about 37 resumes each second. From there a larger burst only waits longer, and twice the burst took twice the time. A first start from the golden snapshot ran at the same rate: 34 to 38 each second for 150 new actors, in three waves.

We measured a second run on the same guest, on a new kind node with a limit of 700 pods. The pool grew from 50 to 300 workers in steps of 50, and each step was ready in 17 to 21 s. At every step the Kubernetes API answered /readyz in a median of 105 to 108 ms, so 300 workers did not slow it. The actor was the same counter, and each request opened its own connection. A single resume took a median of 0.35 s, in 20 resumes. Raw runs

Resumes at one timeBurstsMedian of each burst95th percentileResumes each second
15033.15 to 3.55 s3.83 to 4.09 s35 to 39
20024.33 and 4.55 s5.42 and 5.61 s35 and 36
30027.29 and 7.42 s9.10 and 9.17 s32 and 32

1,448 of 1,450 requests in these bursts returned 200. The other two got no answer in 90 s, and the rate counts the answered requests up to the slowest answer. A first start of 300 new actors, 150 at a time, ran at 30 and 34 each second. So the rate held at 32 to 39 from 50 to 300 at one time, and fell a little at 300.

What sets that rate

Figure 4.5c shows the restore inside the worker, the step of a resume that took the most time.

Fig. 4.11the worker restore of each actor, alone and inside a burstchart
The worker restore of each actor, alone and inside a burst One dot for each of 1192 restores that atelet logged on one node with 24 vCPU. Alone, the restore inside the worker took a median of 0.27 s in 20 restores. Inside 8 bursts of 134 to 150 restores, a median of 75 to 116 ran at the same time, and the median restore took 2.1 to 2.9 s. Each burst finished 35 to 41 restores each second. a burst: median, box of the middle half, line from 5 to 95 percent one restore alone number over a burst: median count of restores at once 0 0.5 1 2 3 4 alone burst 1 burst 2 burst 3 burst 4 burst 5 burst 6 burst 7 burst 8 seconds of the worker restore, each actor 106 104 116 110 75 90 96 102 0.27 s bursts of 134 to 150 resumes; each burst finished 35 to 41 restores each second
Inside a burst about 100 restores ran at once, and each one took about ten times as long as a restore alone. The first column has 20 restores sent one at a time, one dot each. Each other column is one burst of 134 to 150 resumes, as a box of its restore times. The number over a box is the median count of restores in progress at one moment. The bursts of 200 and 300 are not in the log.

We measured this in the second run, from the restore log of atelet and the CPU of each process, sampled each second. Alone, the restore inside the worker took a median of 0.27 s, in 20 restores. In eight bursts, a median of 75 to 116 restores were in progress at one moment. Each one took a median of 2.1 to 2.9 s inside the worker, and each burst finished 35 to 41 restores each second. So nothing limits how many restores start at once, and each one slows when about 100 run together. Raw runs

We ruled out the parts that we could change or watch, in 13 bursts with no hung request:

  • ate-api-server: with 1, 2, and 4 replicas the rate was 36 to 37, 35 to 39, and 32 to 36 each second. It used 0.24 to 0.39 cores on average in a burst, and no thread more than 0.06 cores.
  • The router: with 1 replica of atenet-router the rate was 35 to 39, and with 4 replicas 35.5 and 36.3.
  • Postgres, kube-apiserver, and etcd: each used less than 0.25 cores on average, and no thread more than 0.07 cores.
  • The client: one or four curl processes gave the same rate. So did one connection for each request, against one or two in the first run.
  • The node: 9.5 to 11.4 of its 24 cores were busy on average in a burst, with peaks of 14 to 16. I/O wait was 0.02 to 0.12 cores on average, in the bursts that recorded it.

The busiest processes were the gVisor sandboxes, at 2.1 to 2.9 cores on average, and the worker processes, at 1.5 to 2.2. So the limit is on the node, inside the gVisor restores that run together. Plain CPU, disk wait, and the control plane of Substrate are ruled out. We did not measure its cause. Lock contention in the guest kernel or in gVisor, and a cost of nested virtualisation, are possible. We are sure of what the limit is not, and less sure of where it is.

More replicas of ate-api-server

We measured two bursts of 150 with four replicas of ate-api-server, in the second run. 33 of 300 requests returned 500 and 3 returned 503. The bundled Postgres allows 100 connections, and the four replicas ran it out of them. The API server logged FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute (SQLSTATE 53300) and FATAL: sorry, too many clients already. Its arguments have no flag for the size of its connection pool. Raw runs

Eight actors ended ACTOR_STATE_CRASHED, and one revert brought each one back, in 0.33 to 0.38 s. Two more actors stayed in ACTOR_STATE_RESUMING for at least 4 minutes, and section 8.2 has them.

Our position: keep the two replicas of ate-api-server that the install sets, when you use the bundled Postgres. More replicas did not raise the rate on one node, and four of them failed resumes and crashed actors. If you need more replicas for availability, raise the connection limit of Postgres first. We did not test that change.

The count of stored actors

The count of stored actors did not change these numbers. In the first run we raised the count to 10,750 actor rows in Postgres. A single resume took a median of 0.36 to 0.38 s at each of four sizes, with 10 resumes each. The database was 20 MB at 10,750 rows, and kubectl ate get actors took 0.86 s. The creates ran at 76 to 86 each second, and none of 10,000 failed.

Our position: plan the pool for the burst, and not only for the peak count. If 100 sessions wake in the same second on one node like ours, the last one waits about 2.7 s. With 300 it waits about 9 s. Spread a wake-up of many actors over time in your client. More nodes are the other answer, and we did not test them, so measure that rate yourself.

Two limits stopped the runs. Each suspended actor of this kind kept 12.9 MB on the node disk, and 450 of them left 3.9 GB free on the guest. We also stopped at 10,750 rows, below the 20,000 that we planned. In the second run the delayed upload of 300 snapshots took the guest disk from 4.9 to 4.0 GB in one minute. So 200 and 300 at one time ran twice each, and not three times. An idle worker used a median of 16.7 MB of memory, in 200 workers.

No autoscaler

Substrate v1.0.0 has no code that changes spec.replicas. The WorkerPool has a scale subresource, so a HorizontalPodAutoscaler or your own controller can change the count. The controller publishes ate.workerpool.desired_workers and ate.workerpool.ready_workers for that purpose. We did not test an autoscaler.

Plan as if you have none. Request parking holds a request for 5 seconds. Do not plan for a new worker pod to start and register in that time.

We measured two such starts, each once. On the x86 server guest (24 vCPU, 47 GB, nested KVM), a new pool of 200 workers was ready after 75 s. On the second x86 server guest (16 vCPU, 23 GB), we scaled a pool from 4 workers to 6 while a request waited for room. That request returned 200 in 1.55 s, 1.7 s after the scale command. Both clusters had the worker image on the node already, so a first pull is not in these numbers. Raw runs

Size the pool for the peak of running actors, with room to spare.

What others reported at scale

Our largest cluster had two worker nodes. Our largest count was 10,750 actor rows on one node, of which 150 had a snapshot of their own. The project site states 10× the density of container runtimes. The documentation gives a benchmark method and no result. We measured two parts of that claim and made no comparison with a container runtime. A suspended actor used no worker and 12.9 MB of node disk, and an idle worker used 16.7 MB of memory.

These reports come from the issue tracker of Substrate. Their authors ran them, and we did not.

ReportConditionsResult
Issue 3479, pools without limits320 workers, eight on each 8 vCPU node, no limits on the poolevery worker registered the whole node, so placement never refused an actor
Issue 3408, density on one nodeone node with 192 vCPUs and 503 GiB, gVisor, a development buildat 6,200 actors, 1,187 sandboxes died and all actors stayed RUNNING. With a newer gVisor build, 5,600 actors ran with no deaths, and at 6,000 the node ran out of memory
Issue 3702, Postgres under load9,000 actors in create and delete cycles, 900 workers, v1.0.0-rc.1Postgres used 53 to 60 of 64 cores. The first command came 4 to 18 seconds after create. With 2,700 workers the wait went away
Issue 3101, disk after asynchronous suspendsa 50 GB node disk, 10 suspends of 512 MB eachthe kubelet evicted both worker pods and then atelet, and the node stayed under disk pressure for more than 34 minutes

Read the table as four separate limits: missing limits, node memory, Postgres row locks, and node disk. Each one appeared before CPU ran out. The Postgres report adds one design hint. Many small workers behaved better than few large ones at that scale: about 3 actors on each worker, not 10.

A procedure

  1. Measure the peak memory of one actor, and set the template limit a little above it.
  2. Set the CPU limit of the actor to 250 millicores or more.
  3. Choose the actors for each worker. Set the worker limits to that count times the booking of one actor, plus 64 MiB. On microvm the memory booking is the limit plus an overhead, 7.6 MiB in our run.
  4. Set replicas to the peak of running actors divided by that count, plus spare workers.
  5. Size the node disk for 30 minutes of snapshots, and watch it.
Part 5

Identity and Access

Every caller and every actor has an identity, and the default install lets all clients share one.

  1. 5.1Client authentication
  2. 5.2Access policies per atespace
  3. 5.3Actor JWTs

Client authentication

Substrate checks a credential at two places, the API and the router, and the default install gives every kubectl ate user one shared owner identity.

A colleague gets access to your test cluster and runs kubectl ate get actors. The command prints a warning, then lists every actor of every team. The colleague can also suspend, revert and delete them. Nothing is broken. The install works this way until you change it.

When you finish this section, you can name the credential that each kind of client needs. You can also say what the default install lets every user do.

This section mixes two kinds of evidence. A sentence marked as seen in our runs comes from a recorded probe, and so does every listing. The other statements come from the code and the documentation at v1.0.0.

Two places check a client

PlaceWho calls itCredentialWhat decides access
ate-api-server, the APIkubectl ate, a harness, your controllera client certificate, or a bearer JWTaccess policies, in section 5.2
atenet-router, the path to an actorwhatever sends requests to actorsa client certificateone list of allowed identities for the whole install

The two places share no rule. A principal can own an atespace in the API and still be unable to call an actor. The router serves it only if its certificate is on the list of the router.

The API: a certificate or a token

Principal: the identity that ate-api-server derives from a credential, and the name that access policies grant roles to.

ate-api-server accepts two credentials, in this order:

  1. A client certificate from the pod identity signer of the cluster. The principal is the SPIFFE ID in the certificate, such as spiffe://cluster.local/ns/kagent/sa/kagent-controller.
  2. A bearer JWT from an issuer that the server trusts. The principal is the sub claim. For a Kubernetes ServiceAccount token that is system:serviceaccount:<namespace>:<name>.

A call with neither gets the gRPC error Unauthenticated. The install trusts one issuer, the Kubernetes API server of the cluster, for tokens with the audience api.ate-system.svc. The list of issuers is a ConfigMap that the server reads only at startup.

What the default install does

Every kubectl ate command in our runs began with the same line.

outputthe first line of every kubectl ate commandFull log
Warning: no --token-file given; acting as the shared identity system:serviceaccount:ate-system:ate-client rather than as yourself. Pass --token-file to act as yourself.

Without --token-file, the CLI asks Kubernetes for a token of the ServiceAccount ate-client and sends that. Every person who uses the CLI this way is the same principal. The installer told us what that principal can do.

outputthe warning that ate-setup printsFull log
Warning: system:serviceaccount:ate-system:ate-client is a bootstrap owner, so anyone who can mint a token for it is a global owner of this install. It is the default so the demos, benchmarks and e2e tests work; for a shared or production install, set --ateapi-authz-bootstrap-owners (ATE_API_AUTHZ_BOOTSTRAP_OWNERS) to your operators' own principals. See docs/authorization.md.

So the true access rule of a default install is a Kubernetes rule. Whoever can create a token for ate-client, or create a pod in ate-system, owns every actor.

To act as yourself, create a ServiceAccount for each person and pass its token. We ran these commands for a ServiceAccount named alice, against the atespace lab-v.

bash
kubectl create namespace ate-users
kubectl -n ate-users create serviceaccount alice
kubectl create token alice -n ate-users --audience api.ate-system.svc --duration 1h > alice.token
kubectl ate --token-file alice.token get actors -a lab-v

The file holds a bearer token. Run umask 077 before you create it, so that no other user of the machine can read it.

The principal is then system:serviceaccount:ate-users:alice. It can do nothing until an access policy grants it a role. The server accepted the token and refused the call.

outputa valid token with no roleFull log
…Error: failed to list actors: rpc error: code = PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aate-users%3Aalice" lacks "can_list_actors" on "atespace:lab-v" 
One line of the trace. The time and the step name at the start of the line are cut.

PermissionDenied means that the server knows who you are. A credential that it cannot verify gets a different error, and the error does not say why.

What --token-file heldResult of get actors
a token for alice with the audience api.ate-system.svc, and no rolePermissionDenied, with the principal and the missing permission
a token for alice with the default audience of KubernetesUnauthenticated desc = invalid bearer token
a text that is not a tokenUnauthenticated desc = invalid bearer token

We ran each row once on the x86 server guest. The second row is the mistake to expect: kubectl create token without --audience gives a token that Substrate rejects. Access policies continues with the same ServiceAccount.

Three facts from the documentation limit how far you can trust this setup:

  • A token outlives its ServiceAccount. The server checks the signature and the expiry, and it does not ask Kubernetes if the account still exists. The token works until its expiry time plus five minutes, which the server allows for clock differences. To stop a person at once, remove the person from the access policies. A bootstrap owner is in no policy: remove it from the server flag and restart ate-api-server.
  • The CLI needs port-forward rights. Without --endpoint, kubectl ate opens a port-forward into ate-system. In practice that right covers every pod and port in the namespace, so it also reaches the admin ports of the dataplane.
  • Issuers are not part of the principal. If you trust two issuers, either one can issue a token with the sub of a principal from the other.

A network rule stands before all of this. In our kagent run, a pod in the namespace default could not open a connection to the API at all. The same probe from the namespace kagent reached the server. The install admits only namespaces with the label ate.dev/api-access=true.

The router: a certificate on one list

A request to an actor does not go to the API. It goes to atenet-router, and the router demands a client certificate whose SPIFFE ID is on its list. The list is the router flag --ingress-allowed-spiffe-ids. In our ate-setup installs it held one identity, spiffe://cluster.local/ns/ate-system/sa/atenet-client. A kagent install is different. The kagent installer sets the list to the SPIFFE ID of the kagent controller, and atenet-client is not on it.

We sent the same request from three clients, on the Envoy dataplane and then on the agentgateway dataplane. The target was the actor my-counter-1 in the atespace ate-demo-counter. The figure sets the two checks side by side, with the results of the Envoy dataplane.

Fig. 5.1the two places that check a clientflow
The two places that check a client, and what each one decides Two bands. In the upper band, kubectl ate with no token file presents the shared identity ate-client to ate-api-server, which accepts a certificate or a bearer JWT and then applies access policies. That identity is a bootstrap owner, so it can manage every actor. In the lower band, three pods call atenet-router, which demands a client certificate whose SPIFFE ID is on one list. The identity atenet-client is on the list and gets 200 from my-counter-1, in an atespace where it holds no role. The identity ns/probe/sa/default gets the TLS alert certificate unknown. A pod with no certificate gets a closed connection. THE API: WHO CAN MANAGE ACTORS kubectl ate no --token-file, so it acts as ate-system:ate-client ate-api-server certificate or bearer JWT then the access policies every actor a bootstrap owner of all atespaces THE ROUTER: WHO CAN CALL AN ACTOR atenet-router client certificate, and its SPIFFE ID on one list: --ingress-allowed-spiffe-ids allowed identity ns/ate-system/sa/atenet-client 200 from my-counter-1 any actor in any atespace identity not on the list ns/probe/sa/default TLS alert certificate unknown no client certificate a plain pod connection closed curl: Broken pipe The refusals are those of the Envoy dataplane. The list names identities and no atespaces.
The API and the router check a client separately: the shared ate-client token owns every actor, and the router serves any identity on its one list. Read each band from left to right. Olive is the client, blue is the API, and indigo is the router. Green is what the client reaches, and rose is a refused connection. The refusals are those of the Envoy dataplane.
outputEnvoy, a certificate with the wrong identityFull log
* TLSv1.3 (IN), TLS alert, certificate unknown (558):
curl: (56) OpenSSL SSL_read: OpenSSL/3.3.2: error:0A000416:SSL routines::ssl/tls alert certificate unknown, errno 0
Two lines from the probe on port 443. The certificate named spiffe://cluster.local/ns/probe/sa/default.
outputagentgateway, no certificateFull log
curl: (56) OpenSSL SSL_read: OpenSSL/3.3.2: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
One line from the probe on port 443.
outputthe allowed identity on agentgatewayFull log
cert right-id: subject=CN = HBCORTIJRCAXAVJEJY3YTOYS3X X509v3 Subject Alternative Name:      URI:spiffe://cluster.local/ns/ate-system/sa/atenet-client
### 23-rightid-https-443
preserved memory count: 7 | preserved file counter: 7 | preserved rootfs counter: 7
 code=200 t=0.035022
The first line is the certificate that the client presented.

Three results from these probes matter to you:

  • Both dataplanes authenticate. The router Service of the agentgateway dataplane lists the plain ports 80 and 8081. Nothing listened on them, and both TLS ports demanded a certificate. A later probe of every port gave the same result for 443 and 8444 on both dataplanes. It found one port that needs no credentials: the stats port 15020 of the agentgateway router, which answers /metrics to any pod. The router and ingress has the port table.
  • The allowed ServiceAccount does not exist. After the install, ate-system had no ServiceAccount named atenet-client. We created it before the third probe. kubectl ate proxy gets a certificate for that identity by another path, which the router and ingress describes.
  • The list has no atespaces. The one allowed identity reached an actor in an atespace that it has no role in. A user reported the same as issue 3589.

The last result decides your design. The router answers one question: is this client on the list. It does not ask which actor the client is allowed to call. Put your own service between your users and the router, give only that service an allowed identity, and check each user there.

We did not test a second token issuer, a token of a deleted ServiceAccount, or a list with more than one identity.

Access policies per atespace

An access policy grants one of three roles on one atespace, and the atespace is the smallest unit that Substrate can protect.

Two teams share a cluster. Team A must not suspend, read, or copy the actors of team B. You look for a way to grant access to one actor or one template, and the API has none. The unit of access is the atespace, and that fact shapes how you divide your work.

When you finish this section, you can write an access policy, say what each role allows, and name the things that a policy cannot separate.

Most of our runs used the default owner. For this section we used one ServiceAccount, alice, and the atespace lab-v. We gave it no role, then the viewer role, then the editor role, and we recorded what the API allowed. The denial texts and the policy listings below come from that run. The rules that we did not run are marked as code or documentation.

How a call is checked

ate-api-server checks every call in two steps. Authentication turns the credential into a principal, as client authentication describes. Authorization then allows the call in one of three cases:

  • the principal is a bootstrap owner
  • an access policy gives the principal a role that permits the call
  • the caller is a Substrate component with the system role that the call needs

In every other case the call fails with PermissionDenied. Authorization cannot be turned off. The model runs inside ate-api-server on an embedded OpenFGA engine, and it stores its data in the same Postgres database as the actors.

Access policy: the list of role bindings for one scope. Each scope has at most one policy, and its name is default.

Fig. 5.2two scopes of access policy, and the roles of eachtree
Two scopes of access policy, and the roles of each A tree. At the top, the bootstrap owners from the flag --authz-bootstrap-owners always hold owner on the global scope. Below, the global access policy named default grants owner and viewer. Under it, two atespaces named team-a and team-b each have one access policy named default that grants owner, editor and viewer. Workers belong to the global scope. System roles for atenet-router, atenet-egress, atelet, ate-controller and the ate-api-server pod come from pod certificates and no policy can grant them. --authz-bootstrap-owners default: the shared ate-client global access policy name: default roles: owner, viewer always owner atespace team-a access policy default owner, editor, viewer atespace team-b access policy default owner, editor, viewer a global role holds in every atespace workers global scope only SYSTEM ROLES atenet-router resumes actors atenet-egress mints actor JWTs atelet reads egress policy ate-controller manages workers ate-api-server sidecars read actors from pod certificates no policy grants them
A role on the global scope holds in every atespace, and a role on one atespace holds only there. Read from the top down. Blue boxes are the scopes that a policy can cover. The dashed box holds the system roles, which come from pod certificates. The atespace names are examples.

Two scopes, five roles

A policy covers the global scope or one atespace. The global scope has two roles and an atespace has three. Roles inherit: an owner is also an editor, and an editor is also a viewer.

RoleCan doCannot do
global ownerall of the rows below, create atespaces, manage workersmint an actor JWT
global viewerread every atespace, list workerschange anything
atespace ownerall that an editor can, write the policy of the atespace, delete the atespacetouch another atespace
atespace editorcreate, resume, suspend, revert and delete actors, write templates, egress policies and tagschange the policy
atespace viewerread actors, templates, tags, egress policies and the policychange anything

Workers have no atespace. Only a global role can list them, so a team with an atespace role cannot run kubectl ate get workers.

Bootstrap owners

Bootstrap owner: a principal from the server flag --authz-bootstrap-owners that always holds the global owner role, whatever the policies say.

The server reads the list at startup and does not start with an empty list. With ate-setup, the default is the shared ServiceAccount ate-client, and the installer prints a warning about it. The kagent distribution of Substrate adds its own controller to the list. We read this argument from the ate-api-server Deployment of our kagent cluster:

yamlthe bootstrap owners in a kagent installFull log
        - --authz-bootstrap-owners=spiffe://cluster.local/ns/kagent/sa/kagent-controller,system:serviceaccount:ate-system:ate-client
One argument of the ate-api-server container.

Two principals own that install. The first is the pod certificate of the kagent controller. The second is still ate-client, so the warning from client authentication applies to a kagent install as well.

For a shared cluster, set the list to the principals of your operators and keep it short. Grant daily administrators the global owner role in the global policy. You can remove a role in a policy through the API. You can remove a bootstrap owner only with a restart of the server.

Write a policy

A policy is a short manifest. This is the file that gave the ServiceAccount alice the viewer role on lab-v.

yamlthe policy file that we createdFull log
metadata:
  name: default
bindings:
- role: viewer
  members:
  - user:system:serviceaccount:ate-users:alice
The complete file.

A member is the principal with the prefix user:. Three commands create, read, and change a policy. They follow the pattern of the other kubectl ate objects:

bash
kubectl ate create access-policy -a lab-v -f policy-viewer.yaml
kubectl ate get access-policy -a lab-v -o yaml > current.yaml
kubectl ate update access-policy -a lab-v -f current.yaml

The fourth command removes every binding of the scope. Run it only when you mean that:

bash
kubectl ate delete access-policy -a lab-v

For the editor role we changed one word in the output of get -o yaml and sent that file with update. This is the stored policy after the update.

yamlthe stored policy after the update to editorFull log
bindings:
- members:
  - user:system:serviceaccount:ate-users:alice
  role: editor
metadata:
  createTime: "2026-11-12T12:51:47.968679335Z"
  name: default
  uid: a06fc82d-e269-4ce3-8471-00c1b537adff
  updateTime: "2026-11-12T12:51:51.605795572Z"
  version: "2"
The complete file. The server added the uid, the times, and the version.

Use --global in place of -a for the global policy. Four rules save you a failed command:

  • A global policy accepts owner and viewer. An atespace policy also accepts editor.
  • update replaces the whole policy. It needs the metadata.uid and metadata.version that get -o yaml prints, and it fails if the policy changed after your get.
  • You cannot delete the global policy. Update it with no bindings.
  • A change applies to the next call.

Egress policies follow the same update rule. Our first update egress-policy failed until the manifest carried both fields.

What each role was refused

We ran the same ServiceAccount through four stages. Each denial has the gRPC code PermissionDenied and the same sentence, with the missing permission and the scope in it.

outputa viewer tries to create an actorFull log
…Error: failed to create actor: rpc error: code = PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aate-users%3Aalice" lacks "can_create_actor" on "atespace:lab-v" 
One line of the trace. The time and the step name at the start of the line are cut.
Role of alice on lab-vCallResult
nonelist actors in lab-vdenied: lacks "can_list_actors" on "atespace:lab-v"
nonelist atespacesdenied: lacks "can_list_atespaces" on "global:root"
viewerlist actors and templates in lab-vallowed
viewercreate an actor in lab-vdenied: lacks "can_create_actor" on "atespace:lab-v"
viewerlist actors in the atespace lab-cdenied: lacks "can_list_actors" on "atespace:lab-c"
viewerlist workersdenied: lacks "can_list_workers" on "global:root"
editorcreate, suspend, and delete an actor in lab-vallowed
editorcreate an actor in lab-cdenied: lacks "can_create_actor" on "atespace:lab-c"
editorcreate an access policy on lab-cdenied: lacks "can_create_access_policy" on "atespace:lab-c"
none, after the owner deleted the policylist actors in lab-vdenied: lacks "can_list_actors" on "atespace:lab-v"

We measured the delay of a role change on the x86 server guest (24 vCPU, 47 GB, nested KVM), once for each change. The first call 2 seconds after the create was allowed, and so was the first call 2 seconds after the update. The call 0.2 seconds after the delete was denied. So a change took effect within 2 seconds, and the delete within 0.2 seconds. Raw runs

Two details of the denial help you. The error names the principal with each colon written as %3A, so decode it before you paste it into a policy. And the scope global:root in a denial means that no atespace role can allow the call.

After the install, the global scope had no policy at all: kubectl ate get access-policy --global answered global has no access policy. The shared ate-client identity still owned everything, because it is a bootstrap owner.

What a policy cannot separate

The atespace is the only boundary. Five limits follow, and you must design around each of them.

  • No grant for one actor or one template. An editor of an atespace can change every actor in it.
  • No groups. A member is one principal. A team of ten needs ten members.
  • An editor can copy an actor. A tag is a copy of the memory and the disk of an actor, with every secret in it. Any editor can create a tag and start a new actor from it.
  • Names carry the grants. If you delete an atespace and create one with the same name, a late request for the old one can apply to the new one. Never use an atespace name two times. Atespaces and names explains the naming rules.
  • The router does not read policies. A client on the list of the router reaches actors in every atespace.

Our kagent run adds a practical case. kagent put every session of every agent into one atespace named kagent. In that install, Substrate policies cannot keep two kagent users apart. That job belongs to kagent.

System roles

The components of Substrate hold roles that no policy can grant. Each role is bound to the pod certificate of one component by a flag of ate-api-server.

RoleHeld byAllows
ingress_routeratenet-routerresume any actor, list actors in all atespaces
egress_gatewayatenet-egress, and any other identity that you name with the repeatable flag --egress-gateway-spiffe-idread actors and egress policies, mint actor JWTs
ateletatelet on every noderead the egress policy of any actor
controllerate-controllercreate, get, list, update, delete and drain workers
api_server_sidecarthe ate-api-server pod, which its sidecars shareread actors, so that the object store plugin can limit a storage credential to one actor

Two rows deserve a second look. The router can resume every actor, which is why the list of the router in client authentication matters so much. And only the egress gateway can mint an actor JWT. A global owner cannot, because the token lets its holder act as the actor. Actor JWTs follows that token.

Our position

Use one atespace for each group of people that must not read the others' actors, and accept that this is the only line you get. Replace the default bootstrap owner before a second person uses the cluster. Do not count on Substrate to separate the end users of your product. Your own service has to do that, in front of both the API and the router.

Actor JWTs

An actor does not store its own identity token: the egress gateway mints an actor JWT and puts it in a header that an egress rule names.

Your actor calls a service that you run, and the service must know which actor is calling. The usual answer is a secret inside the workload. An actor is a bad place for a secret, because its memory goes into every snapshot and any editor can copy it. Substrate gives the actor an identity that the actor does not store. The actor can still read the token when a service returns it.

When you finish this section, you can write the egress rule that attaches an actor JWT and list the claims in the token. You can also say what a service must check before it trusts one.

Our runs show two such rules at work. One is the rule that kagent writes for each session. The other is our own rule, which sent a token to a service that returns its request headers, so we could decode the token. The limits at the end come from the documentation at v1.0.0, and we say which of them we saw.

The gateway asks, the actor does not

Actor JWT: a signed token, minted by ate-api-server, that names one actor and one or more audiences.

No API lets an actor request its own token. The token appears only on outbound requests, and only where an egress rule asks for it.

Fig. 5.3how an actor JWT reaches the service that an actor callssequence
How an actor JWT reaches the service that an actor calls A sequence with four lifelines: the actor session-01a1cf43, the egress gateway atenet-egress, ate-api-server, and the service kagent-controller on port 8083. The actor sends a plain HTTP request. The gateway matches the egress rule, calls MintActorJwt with the audience kagent-runtime and 300 seconds, replaces the authorization header with the token, and sends the request on. The service answers 200. The actor never holds the token. actor session-01a1cf43 atenet-egress egress gateway ate-api-server signs with ES256 kagent-controller port 8083 1 HTTP request no credential 2 rule matches: http, port 8083 effect: actorJwt 3 MintActorJwt kagent-runtime, 300 s token 4 authorization: Bearer token 200
The egress gateway mints the token and replaces a header with it, so the service receives an identity that the actor did not send. Read from the top down. Green is the actor, indigo is the egress gateway, blue is the control plane, plum is the kagent controller. The names and the numbers are from the policy of one kagent session.

The steps are these:

  1. The actor sends a plain request to a host that its egress policy allows.
  2. The egress gateway finds the rule for that host and sees the effect actorJwt.
  3. The gateway calls MintActorJwt on ate-api-server, with the audiences and the lifetime from the rule.
  4. The gateway writes the token into the header that the rule names, and sends the request on.

Only the egress gateway is allowed to call MintActorJwt. A global owner is refused, as access policies per atespace explains.

The rule that kagent writes

kagent needs this mechanism for its own traffic. Each session is an actor, and the harness in the actor reports to the kagent controller. This is the first rule of the egress policy that kagent wrote for one session in our run.

yamlan egress rule with an actor JWTFull log
rules:
- http:
    effects:
      replaceHeaders:
      - actorJwt:
          audiences:
          - kagent-runtime
          expirationSeconds: "300"
        header: authorization
        prefix: 'Bearer '
    hostnames:
    - kagent-controller.kagent.svc.cluster.local
    ports:
      numbers:
      - 8083
The first of three rules. The metadata and the other two rules are cut.

Read it from the bottom up. The rule matches plain HTTP to one host on port 8083. For a matching request, the gateway replaces the authorization header with Bearer and a fresh token. The token is for the audience kagent-runtime, and it lives 300 seconds, the shortest lifetime that the API accepts.

The log of the egress gateway shows the result. The harness called the controller, the request carried an authorization header, and the controller answered 200.

outputone request that carried the tokenFull log
2026-11-12T10:19:50.777573Z	info	request request.id=64 gateway=default/default listener=listener1 route=default/route0 endpoint=kagent-controller.kagent.svc.cluster.local:8083 src.addr=10.244.0.23:59184 http.method=POST http.host=kagent-controller.kagent.svc.cluster.local http.path=/kagent.runtime.v1.TaskStoreService/SettleTask http.version=HTTP/2.0 http.status=200 grpc.status=0 protocol=http ate.actor.name=session-01a1cf43-d760-747a-b907-5e0640126e4e ate.actor.uid=d64e21f4-05eb-49a3-a81e-69ca2d893b06 ate.atespace=kagent …
One line, cut near its end. The agentgateway dataplane prints the value of the authorization header as Sensitive in its debug lines.

This rule is on the http kind, so the token crossed the cluster network without TLS between the gateway and the controller. Use the https kind for any service outside your cluster. Credential injection covers the other effect of replaceHeaders, which reads a secret.

What the token says

We wrote a rule like the one of kagent for an actor named jw2 in the atespace lab-v. The audience was lab-echo, the lifetime was 600 seconds, and the host was a service in the cluster that returns its request headers. The actor called that service, and we decoded the token that came back. The listing shows the header and the claims, and it leaves out the signature.

outputthe header and the claims of one actor JWTFull log
…
header: {
 "typ": "JWT",
 "alg": "ES256",
 "kid": "E38XLIprTZzZkY7RinXF7sPyw-HxHT-ysnVHFvvTCAU"
}
claims: {
 "iss": "https://idp.ate-system.svc",
 "sub": "actor/lab-v/jw2",
 "aud": [
  "lab-echo"
 ],
 "exp": 1794488570,
 "nbf": 1794487670,
 "iat": 1794487970,
 "jti": "VLZDMDBJ2UA3ME2Z5M62BADKQK",
 "actor.ate.dev": {
  "atespace": "lab-v",
  "actorName": "jw2",
  "actorUID": "93e670a4-3215-41e5-be33-4dffaca75d42"
 }
}
…
The request headers at the start of the file and the two lines after the claims are cut.
ClaimValueIn our token
issthe issuer URL of the installhttps://idp.ate-system.svc
subactor/<atespace>/<name>actor/lab-v/jw2
audthe audiences from the rule, always an array["lab-echo"]
expthe issue time plus expirationSeconds, which must be 300 to 3600iat plus 600 seconds
nbffive minutes before iatiat less 300 seconds
iatthe issue timepresent
jtia random idpresent
actor.ate.devan object with atespace, actorName and actorUIDall three fields

The header named the algorithm ES256. Its kid was equal to the one key in the key set that the issuer published. The code also allows RS256.

We saw four more things in that run, once each, on the x86 server guest:

  • The gateway uses a token again. Two requests in a row carried the same token, with the same jti. A request after a suspend and a resume of the actor carried it too. So jti is not a request id, and a suspend does not end a token.
  • The actor must send the header. The effect replaces a header and does not add one. A request that the actor sent without an authorization header reached the service with none. Our other requests sent Bearer placeholder, and the gateway replaced it.
  • The actor can get the token. The service returned its request headers, so the actor read its own token in the response. Send a token only to a service that does not return it.
  • One claim was absent. The discovery document lists a claim named worker-for-actor.ate.dev. Our token did not have it.

Two details change how you use the token:

  • sub is a name. An actor that you delete and create again with the same name gets the same sub. Bind to actorUID if the difference matters.
  • Substrate adds no audience. The rule decides aud, and the person who writes the rule also decides which host receives the token.

Create the policy before the actor asks for the name. In our first try, the actor asked for the host before any policy existed. We created the policy, and 4.5 seconds later the name still did not resolve. In the second try the policy came first, and the name resolved at once. The DNS relay describes one more slow update like this one.

What the service must check

A service that receives the token is a relying party in OpenID Connect terms. ate-idp-server publishes the discovery document at /.well-known/openid-configuration under the issuer, and the keys at /openid/v1/jwks. Our kagent install set the issuer to https://idp.ate-system.svc.

The service must do five checks:

  1. iss equals the issuer that you configured.
  2. The signature verifies with the key from the key set that has the kid of the token.
  3. aud contains the audience that the service expects.
  4. exp and nbf fit the current time.
  5. sub starts with actor/.

Do not skip the last check. The same keys sign a second kind of token, with a sub that starts with worker-for-actor/. Substrate components use that kind to move snapshots. A service that accepts it treats a worker as the actor.

The default issuer is a Service name, so only a verifier inside the cluster can reach it. For a verifier outside, you must publish the two paths on a host that you own and set the issuer to that host. The installer does not create the Ingress, the DNS record, or the certificate.

Limits

The documentation lists these limits. We saw the second one in our run, and we tested none of the others:

  • No revocation. A token is valid until exp, also after you delete the actor. Keep the lifetime at 300 seconds unless a service needs more.
  • It is a bearer token. A service that returns its request headers in the response gives the token to the actor. Our header service did that, and we saw the same effect with an injected credential in our egress tests.
  • A new actor with an old name. After you create an actor again, the gateway can send a token with the actorUID of the old one. The window is up to two thirds of the token lifetime (issue 2208).
  • No exchange for cloud credentials. Cloud token services want the JWT in the request body, and the gateway replaces only headers (issue 1661).
  • Key rotation is manual. Keys do not expire. You add, activate and remove keys with kubectl ate admin.

Three things stay unverified after our run. We did not check the signature with the published key, as a service must. We did not rotate a key. And we did not set up federation with a second issuer or a cloud provider.

Our position

Use the actor JWT for calls from an actor to services that you control. It removes a secret from the actor, and the 300 second lifetime bounds the damage of a leak. Do not treat the audience as the whole access decision. Every atespace editor can write a rule that sends a token with any audience to any host. So give each service its own audience, and make each service refuse every other one. Each service must also decide which actors in sub it serves.

Part 6

Network

An actor reaches nothing until a policy names the host, and a removed rule does not close an open connection.

  1. 6.1The router and ingress
  2. 6.2EgressPolicy and default deny
  3. 6.3The DNS relay
  4. 6.4TLS interception and the trust bundle
  5. 6.5Credential injection
  6. 6.6The dataplane choice: Envoy or agentgateway

The router and ingress

Every request to an actor passes through atenet-router, which demands a client certificate with an allowed SPIFFE ID and calls ResumeActor before it forwards one byte.

Your actor answers through kubectl ate proxy. Then a service in another namespace calls the router directly, and the call fails in the TLS handshake with no HTTP status to read. Nothing is wrong with the actor. The router refused the caller before it looked at the request.

When you finish this section, you can address an actor through the router and name the identity that the router accepts. You can also read the three ways that it refuses a caller on each dataplane.

One Service for every actor

An actor has no Service and no hostname. Every actor in the cluster is reached through one Service, atenet-router.ate-system.svc, and the caller names the target in a header.

Routing header: the HTTP header ate-target-actor, with the value <atespace>/<actor>, such as ate-demo-counter/my-counter-1. The Host header stays free for your application.

The Service has two ports that matter. Port 443 carries plain HTTPS requests to port 80 of the actor. Port 8444 accepts an HTTP CONNECT over TLS, for an actor that listens on another port. The routing header goes on the outer CONNECT, and the port goes in its authority. Our probe sent CONNECT actor:80 and got the same answer as on port 443.

Envoy dataplaneagentgateway dataplane
Containers in the router podatenet-router and envoyone agentgateway
Listeners for callers8443 and 84448443 and 8444
Service ports443 and 8444443, 8444, 80, 8081, and 15020
Plain HTTP on 80 or 8081no such Service portconnection refused

The agentgateway Service lists ports 80 and 8081, and the NetworkPolicy admits them. No process listens on them. A plain HTTP request to either port failed in 3 ms with a refused connection. Do not read the Service as proof of an open port.

outputthe router Service on the agentgateway dataplaneFull log
atenet-router             ClusterIP   10.96.5.247     <none>        443/TCP,8444/TCP,15020/TCP,80/TCP,8081/TCP …
One line. The columns for the external IP, the age, and the selector are cut.

A later probe tried every port from two pods. One was in another namespace and had no certificate. The other was in ate-system and held the allowed identity.

PortEnvoy dataplaneagentgateway dataplane
Service 443, container 8443TLS. The connection is closed without a client certificate. With the allowed identity and the routing header: 200. Without the header: 404, invalid actor referenceTLS. Without a client certificate: the alert certificate required. With the allowed identity and the routing header: 200. Without the header: 404, ingress failed
Service 8444TLS. The connection is closed without a client certificate. A POST with the identity gets 404TLS, with the same alert. A POST with the identity gets 405, method not allowed, because the listener accepts CONNECT only
Service 80, container 8080not in the Service, timeoutin the Service, connection refused, no listener
Service 8081not in the Service, timeoutin the Service, connection refused, no listener
Service 15020, statsnot in the Service, timeoutopen with no TLS and no credentials. /metrics returns 200 to a pod in another namespace
Pod port 15021timeout404 on plain HTTP, the readiness listener
Pod port 15000timeouttimeout

We measured each port once on each dataplane, on a second x86 server guest (16 vCPU, 23 GB), with a client timeout of 4 s. Ports 443 and 8444 need a client certificate on both dataplanes. With agentgateway the Service has five ports and the container declares six, and the rendered configuration binds 8443 and 8444 for callers. Ports 80 and 8081 are dead entries in the Service. With Envoy the Service has two ports. Raw runs

outputthree more probes on the agentgateway dataplaneFull log
stats 15020 /metrics from another namespace: 200|13394|58|# HELP agentgateway_substrate_request_parking_active Number of requests waiting for a Substrate actor to become routable.|
…
service 8444 with allowed client cert, POST with routing header: 405 exit=0 err=|method not allowed
service 443 with allowed client cert, no routing header: 404|ingress failed
Three lines of the file. The first is from a pod in another namespace, and the other two used the allowed client certificate. The fields after the status are the size in bytes, the number of metric families, and the first line of the body.

Security note: the stats port of agentgateway needs no credentials. Any pod that can reach the Service can read /metrics on port 15020, in plain HTTP. Our pod in another namespace got 13,394 bytes with 58 metric families, and the names include request parking for actors. We found no actor name and no header value in that output, and we did not read the content of every label. Until you have read your own output, treat the port as readable by every tenant of the cluster. A NetworkPolicy for port 15020 of the router pods closes it.

Use the short name of the Service. The router certificate is valid for atenet-router.ate-system.svc. The longer name atenet-router.ate-system.svc.cluster.local failed the certificate name check on both dataplanes.

One request, step by step

The router keeps no table of running actors. For each request it asks ate-api-server where the actor is, with the same ResumeActor call that wakes a suspended actor. The answer carries the state, the worker IP, and the worker identity. Then the router opens a mutual TLS tunnel to that worker and forwards the request.

Fig. 6.1one request through the routersequence
One request through atenet-router to a running actor Five lifelines: a caller pod with the identity atenet-client, atenet-router, ate-api-server, Postgres, and the worker pod that runs my-counter-1. Seven numbered exchanges: the caller sends POST over mutual TLS with the ate-target-actor header, the router calls ResumeActor, ate-api-server reads the actor row, and it returns ACTOR_STATE_RUNNING with the worker IP and SPIFFE ID. That call took 9.2 milliseconds. The router opens an mTLS CONNECT to the worker, the actor answers 200, and the caller has the 200 after 37 milliseconds. caller pod atenet-client atenet-router Service port 443 ate-api-server control plane Postgres actor rows worker pod my-counter-1 9.2 ms of the 37 ms 1 POST / over mTLS ate-target-actor header 2 ResumeActor for every request 3 read actor row 4 ACTOR_STATE_RUNNING worker IP and SPIFFE ID 5 mTLS CONNECT to the worker then actor port 80 6 200 7 200 37 ms at the caller
The router calls ResumeActor for every request, also when the actor already runs, and only then opens a tunnel to the worker. Read from top to bottom. Step 2 happens for each request, which puts ate-api-server and Postgres on the path of each one. The bracket is the time from step 2 to step 4 for this request, from the router log.

The router log shows the call for an actor that was already ACTOR_STATE_RUNNING. Our prober sent one request per second, and each one produced this pair.

outputthe router asks for a running actorFull log
{"time":"2026-11-12T11:04:04.204261373Z","level":"INFO","msg":"ResumeActor","actor":{"type":"*ateapi.Actor","atespace":"lab-e","name":"e-run"},…
{"time":"2026-11-12T11:04:04.208776711Z","level":"INFO","msg":"ResumeActor result","actor":{"type":"*ateapi.Actor","atespace":"lab-e","name":"e-run"},"state":"ACTOR_STATE_RUNNING","workerIP":"10.244.0.36","workerSPIFFEID":"spiffe://cluster.local/ns/lab-e/sa/ateworker",…
Two lines for one request. The trace fields at the end of each line are cut.

This design has a cost on the warm path. A running actor is only as available as ate-api-server and Postgres. We scaled ate-api-server to zero for 60 seconds. The running actor returned 503 with the body actor lab-e/e-run unavailable for 60 requests in a row. Section 8.2 has the full timeline.

We measured the allowed path on a second x86 server guest (16 vCPU, 23 GB, Envoy dataplane), one request each. A request on port 443 to a running actor took 0.037 s. The router log gives 9.2 ms of that to the ResumeActor call. The same request through CONNECT on port 8444 took 0.032 s. On the agentgateway dataplane the two requests took 0.035 s and 0.029 s. Raw runs

When no worker has room, the router holds the request and tries again. Section 4.4 covers that wait.

Who the router lets in

The router serves TLS only. It requires a client certificate from the pod identity signer of the cluster. Then it compares the SPIFFE ID in that certificate with a fixed list.

Ingress allowlist: the list of SPIFFE IDs that the router accepts, set at install with --ingress-allowed-spiffe-ids. The flag has no default. An identity on the list can reach every actor in every atespace.

That last sentence is the one to remember. The router authenticates the caller. It does not check that this caller is allowed to reach this actor. Put a service of your own in front of it when callers must see only their own actors. Section 5.1 covers the client side.

We sent the same request from three pods. The first had no client certificate. The second had a valid certificate for spiffe://cluster.local/ns/probe/sa/default, which is not on the list. The third had the allowed identity.

CallerEnvoy dataplaneagentgateway dataplane
No client certificateconnection cut, Broken pipe in curlTLS alert certificate required
Valid certificate, identity not on the listTLS alert certificate unknownconnection reset
Allowed identity200 on 443 and through 8444200 on 443 and through 8444
Allowed identity, actor does not exist404, actor ate-demo-counter/does-not-exist not found404, ingress failed
Allowed identity, no routing header404, invalid actor reference404, ingress failed
Fig. 6.2what a caller of the router sees on each dataplanedecision
What a caller of atenet-router sees on each dataplane Five rows in two groups, with the outcome on the Envoy dataplane and on the agentgateway dataplane. In the first group the router refuses the caller in the TLS layer and the caller gets no HTTP status: with no client certificate, Envoy cuts the connection and agentgateway sends the alert certificate required. With an identity that is not on the list, Envoy sends the alert certificate unknown and agentgateway resets the connection. In the second group the caller has the allowed identity atenet-client and gets an HTTP answer: 200 from my-counter-1 in 0.037 seconds on Envoy and 0.035 seconds on agentgateway, 404 for an actor that does not exist, and 404 for a request with no routing header. THE REQUEST ENVOY DATAPLANE AGENTGATEWAY DATAPLANE Refused in the TLS layer: no HTTP status no client certificate a pod with no certificate connection cut curl: Broken pipe TLS alert certificate required identity not on the list ns/probe/sa/default TLS alert certificate unknown connection reset reset by peer, no alert Allowed identity atenet-client: an HTTP answer actor my-counter-1 ACTOR_STATE_RUNNING 200 in 0.037 s 0.032 s through 8444 200 in 0.035 s 0.029 s through 8444 actor does-not-exist no such actor in the atespace 404 in 0.024 s body: actor not found 404 in 0.022 s body: ingress failed no routing header ate-target-actor is absent 404 in 0.018 s body: invalid actor reference 404 in 0.018 s body: ingress failed
Both dataplanes refuse a caller with no certificate or the wrong SPIFFE ID in the TLS layer, so the caller gets no HTTP status. Read each row from the request to its outcome on each dataplane. A dashed rose box is a refusal with no HTTP status. A solid rose box is a 404 with a body. A green box is a request that reached the actor, with the time at the caller.
outputno client certificate, agentgateway dataplaneFull log
* TLSv1.3 (IN), TLS alert, unknown (628):
curl: (56) OpenSSL SSL_read: OpenSSL/3.3.2: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
exit=56
Three lines of curl output. The TLS setup lines before them are cut.

No refusal carries an HTTP status, so a client library reports a transport error. When you debug a caller, look for the TLS alert first. The agentgateway error body is also less useful after authentication: it says ingress failed for an unknown actor and for a missing header.

One claim in older material is wrong for an installed cluster. The agentgateway router does authenticate callers. The installer adds the client certificate check and the allowlist to every agentgateway install that it makes. We also installed with an empty allowlist. The rule in the router configuration became source.spiffeId in [], and the router refused both callers that we tried.

The identity in the quickstart does not exist

The quickstart passes one identity to the installer: spiffe://cluster.local/ns/ate-system/sa/atenet-client. After the install, the namespace ate-system has no ServiceAccount with that name.

outputServiceAccounts in ate-system after the installFull log
NAME                      AGE
ate-api-server            3m33s
ate-client                3m31s
ate-controller            3m31s
ate-idp-server            3m32s
atelet                    3m30s
atenet-egress             3m20s
atenet-router             3m28s
default                   4m18s
k8s-credential-provider   3m21s
The whole list, about three minutes after the install finished.

kubectl ate proxy still works, because it does not run as a pod. It asks the cluster for a certificate with that identity, and it names a pod and a ServiceAccount that do not exist. A pod of yours cannot do that. To call the router from a pod with the quickstart identity, we first ran kubectl create serviceaccount atenet-client -n ate-system. Then we mounted a pod certificate from the pod identity signer.

Do not copy that into production. The quickstart identity is one name that every user of kubectl ate proxy shares. Give each calling service its own ServiceAccount, and list each SPIFFE ID in --ingress-allowed-spiffe-ids. Treat the right to request certificates in ate-system as the right to become any component there.

EgressPolicy and default deny

An actor with no EgressPolicy reaches nothing, and a policy opens named hosts and ports only, through three rule kinds and no CIDR rule.

Your agent runs a tool that downloads a package, and the tool reports that the host does not resolve. You open a shell in the actor and curl fails for every name you try. The network is not broken. The actor has no policy, so Substrate denies all of it.

When you finish this section, you can write an EgressPolicy for one actor and apply it with kubectl ate. You can also tell from the error which layer refused a connection.

The path of an outgoing connection

An actor does not sit on the pod network. Its sandbox has one link of four addresses, and the far end of that link belongs to the worker.

outputthe network that an actor seesFull log
nameserver 169.254.17.1
search ate-demo-egress.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
…
inet 169.254.17.2/30 scope global dynamic eth0
…
default via 169.254.17.1 dev eth0 scope link
Output of resolv.conf, the addresses, and the routes inside the actor f-deny. The loopback and IPv6 lines are cut.

Three parts of Substrate handle what leaves that link:

  1. Firewall rules in the worker redirect every TCP connection to atunnel, a process in the worker pod. Other protocols are rejected, except DNS to 169.254.17.1.
  2. atunnel opens a mutual TLS CONNECT to the Service atenet-egress. It presents the certificate of the actor, and the target is the original IP and port.
  3. atenet-egress verifies the certificate and asks ate-api-server that the actor is running or resuming. Then it reads the policy of that actor and decides.
Fig. 6.3where an outgoing connection is decidedflow
Where an outgoing connection from an actor is decided The actor f-main at 169.254.17.2/30 sends DNS queries to the DNS relay at 169.254.17.1 and every TCP connection to atunnel. Both are in the worker pod. The relay forwards names that the policy lists to the cluster DNS and answers NXDOMAIN for other names. atunnel opens a mutual TLS CONNECT to atenet-egress, which checks the request and connects to the upstream host whoami. The upstream sees 10.244.0.22. A request outside the policy gets 403 or a reset. Both checks read the EgressPolicy of f-main from ate-api-server, and the relay keeps it in a cache for 5 seconds. ate-api-server EgressPolicy default of f-main WORKER POD actor f-main 169.254.17.2/30 DNS relay 169.254.17.1:53 atunnel actor certificate cluster DNS CoreDNS atenet-egress checks the request whoami upstream DNS query every TCP connection listed names other names: NXDOMAIN CONNECT mutual TLS sees 10.244.0.22 outside the policy: 403 or reset policy, cached 5 s policy
A connection from an actor passes two checks, the DNS relay for the name and atenet-egress for the request, and each one reads the same policy. Read from left to right. The upper path is a name lookup and the lower path is the connection itself. The dashed arrows from the top are the policy that each check reads. The upstream host sees the IP of atenet-egress, never the actor.

The upstream host never sees the actor. In our run the test server reported the caller as 10.244.0.22, the pod IP of atenet-egress. Actor networking is IPv4 only at this version.

The policy is an API record, one per actor

EgressPolicy: the list of allow rules for one actor. It is a record in the Substrate API with the fixed name default, stored under the actor. It is not a Kubernetes object, so kubectl get does not show it.

You create the policy after the actor exists. A new actor does not copy the policy of its template. A harness that creates actors must therefore write a policy for each one, which is what kagent does (section 7.1).

A policy holds up to 256 rules, and the order of the rules has no meaning. Each rule is one of three kinds.

Rule kindWhat the gateway readsDefault portCredential effects
httpthe Host header of each cleartext request80yes
httpseach request, after the gateway decrypts the TLS session443yes
tlsPassthroughthe server name in the TLS handshake, once for each connectionnone, you must list portsno

A rule has three fields: hostnames, ports, and effects. A hostname is a lowercase DNS name, or a name with * as its whole first label, or * alone. An IP address is not valid, and no rule kind takes a CIDR range. A rule also has no field for a path, a method, a model, or a tool. The policy decides which hosts an actor can reach. It does not decide what the actor asks them for.

This is the policy of our test actor f-main, as the API returned it. We wrote the three rules. The API added the default ports and the metadata.

yamlan EgressPolicy with one rule of each kindFull log
metadata:
  atespace: lab-f
  createTime: "2026-11-12T10:45:59.339191794Z"
  name: default
  uid: 06d478d7-7a70-4ca0-91f8-4524c40ea9bc
  updateTime: "2026-11-12T10:52:47.315685233Z"
  version: "6"
rules:
- http:
    hostnames:
    - whoami.egress-target.svc.cluster.local
    ports:
      numbers:
      - 80
- https:
    hostnames:
    - example.com
    ports:
      numbers:
      - 443
- tlsPassthrough:
    hostnames:
    - example.org
    ports:
      numbers:
      - 443
The complete output of get egress-policy with -o yaml.

Create and update a policy

The first policy of an actor needs only the rules.

bash
kubectl ate create egress-policy f-main -a lab-f -f policy.yaml
kubectl ate get egress-policy f-main -a lab-f -o yaml

An update is stricter, and we got it wrong twice. The first time, we sent a file with only rules.

outputan update without the preconditionsFull log
Error: manifest for actor "f-main" in atespace "lab-f" lacks the metadata.uid and metadata.version preconditions that "get egress-policy -o yaml" prints
The answer of update egress-policy to a file that has rules and no metadata.

update egress-policy replaces the whole policy, and it needs to know which version you edited. Read the policy with -o yaml, keep metadata.uid and metadata.version, change the rules, and send the file back.

If the stored policy changed between your read and your update, the server refuses the update. The CLI then tells you to run get egress-policy -o yaml again. The CLI itself checks only that both values are in the file. Read the policy again and repeat the change. We did not run that case.

The second time, the file had a tlsPassthrough rule with no ports.

outputa tlsPassthrough rule without portsFull log
Error: failed to update egress policy for actor "f-main" in atespace "lab-f": rpc error: code = InvalidArgument desc = egress_policy.rules[2].tls_passthrough.ports: Required value
The answer of update egress-policy. The rule index counts from zero.

An http rule without ports means port 80, and an https rule means port 443. A tlsPassthrough rule has no default. Write ports: {numbers: [443]} each time.

What a denied request looks like

The two dataplanes enforce the same policy and answer in different words. Learn both sets, because an agent sees these strings in its tool output.

The actor does thisEnvoy dataplaneagentgateway dataplane
Resolves any name, with no policyNXDOMAINNXDOMAIN
Resolves a name that is not in its policyNXDOMAINNXDOMAIN
Queries another resolver, such as 8.8.8.8timeouttimeout
Sends HTTP to an IP, with no policyconnection reset403, actor egress policy denied
Sends HTTP to an IP that is not in its policy403, egress denied403, actor egress policy denied
Opens TLS to an IP, with no policyhandshake cuthandshake cut
Opens TLS with a server name that no rule listshandshake cuthandshake cut
Sends a WebSocket upgrade to an allowed host403403, WebSocket egress is not supported

A name outside the policy never reaches the gateway, because the DNS relay answers first. Section 6.3 covers that relay. The gateway answers only when the actor already has an IP address.

outputHTTP to an IP with no policy, agentgateway dataplaneFull log
HTTP/1.1 403 Forbidden
content-type: text/plain
content-length: 26
…
actor egress policy denied
The response to a request for an IP address from the actor f-deny. The date header is cut.

On Envoy, the same request from the same actor ended with curl: (56) Recv failure: Connection reset by peer. The Envoy text egress denied appears only when the actor has a policy and the request does not match it.

The Host header does not open another host

A policy that names hosts invites one attack: connect to an IP of your choice and claim an allowed name in the Host header. We tried it. The policy allowed only whoami.egress-target.svc.cluster.local. The actor connected to the IP of a second service, drip, and sent the allowed name.

terminala request to another IP with the allowed Host headerFull log
$ curl -sS -m 8 -i -H 'Host: whoami.egress-target.svc.cluster.local' 'http://10.96.185.134/drip?n=1' | head -14
…
HTTP/1.1 200 OK
…
Hostname: whoami-5bdb4584f6-4gqz6
The command and the lines that show which server answered. The headers between them are cut.

The answer came from whoami, not from drip. The gateway took the name from the header, found it in the policy, and connected to that name itself. It ignored the IP that the actor had chosen. Both dataplanes did the same. An actor therefore reaches only the hosts that the policy names, whatever address it dials.

The reverse also holds. A policy names hosts, so it cannot pin an IP. If the name resolves to a new address tomorrow, the gateway follows it.

The DNS relay

The DNS relay answers only for names in the policy of the actor, so a denied host fails as NXDOMAIN before the actor opens a connection.

An agent reports Could not resolve host: example.com. You check CoreDNS, and it is healthy and has no record of the query. You resolve the name from a debug pod and it works. The actor never asked CoreDNS. It asked a resolver that Substrate runs for it, and that resolver read the policy first.

When you finish this section, you can explain each DNS failure inside an actor. You can also say how long a policy change takes to reach name lookups.

One resolver, and it reads the policy

The file /etc/resolv.conf in an actor names one server, 169.254.17.1. That address is the worker end of the link of the actor.

DNS relay: a resolver inside atunnel in the worker pod, with one instance for each actor. It forwards a query to the cluster resolver only when the name matches a hostname in a rule of that actor. The rule kind does not matter. For every other name it answers NXDOMAIN.

The relay marks a denial so that clients do not cache it, and it does not log the name that it denied. That is why your search of the cluster logs found nothing.

The same query from one actor, before and after its policy named the host:

terminalone name, without and with a rule for itFull log
$ dig +time=2 +tries=1 +noall +comments +answer whoami.egress-target.svc.cluster.local | grep -E 'status|IN'
…
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 58397
…
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 26542
…
whoami.egress-target.svc.cluster.local.	30 IN A	10.96.79.253
Two runs of the same dig command. The first actor has no policy. The second has an http rule for the name. Warning lines are cut.

Default deny covers cluster names too. An actor with no rule for the Kubernetes API cannot resolve kubernetes.default.svc.cluster.local. An actor is not a pod, and it gets no cluster DNS for free.

Another resolver does not help

A tool that distrusts the local resolver sometimes queries a public one. From an actor, that query gets no answer at all.

terminala query to a public resolver from an actorFull log
$ dig @8.8.8.8 +time=2 +tries=1 example.com | grep -E 'status|timed out|refused|unreachable' ; echo rc=$?
;; communications error to 8.8.8.8#53: timed out
The command and its output. The actor had no policy.

Only DNS to the worker end of the link reaches the relay. A query to any other server is not DNS traffic for Substrate, and Substrate forwards no UDP out of the sandbox. The result was the same on both dataplanes, because this check happens in the worker and not in the gateway.

Expect the same from DNS over HTTPS. It is an HTTPS request to a host, and the policy decides it like any other host.

Short names against full names

Kubernetes users write short Service names every day, and inside a pod they work. Inside an actor a short name fails in a way that looks like a policy bug. We saw it in the kagent run.

The policy of the kagent actor allowed mock-llm.kagent.svc.cluster.local on port 8080. The search list of the actor was kagent.svc.cluster.local svc.cluster.local cluster.local. We asked for the model list four ways.

Request from the actorResult
mock-llm.kagent.svc.cluster.local:8080200, the model list
mock-llm.kagent:8080403 Forbidden
example.combad address, the resolver answered NXDOMAIN
kubernetes.default.svc.cluster.localbad address

The short name got a 403, not a DNS error. The resolver in the actor added a search suffix, the relay saw the full name, and the lookup succeeded. Then the HTTP request carried Host: mock-llm.kagent:8080. The gateway compares the Host header with the policy, and the policy holds the full name only.

outputfour requests from a kagent actorFull log
A http-outside:
wget: bad address 'example.com'
…
E allowed-host:
{"object": "list", "data": [{"id": "mock-1", "object": "model", "owned_by": "lab"}]}
…
G kube-api:
wget: bad address 'kubernetes.default.svc.cluster.local'
H short-name:
wget: server returned error: HTTP/1.1 403 Forbidden
Output of the tool call in one turn. Lines for the other probes are cut.
Fig. 6.4four names from one kagent actordecision
Four names from one kagent actor Four rows show a name that the actor asked for, the answer of the DNS relay, and the answer of atenet-egress. example.com and the Kubernetes API name get NXDOMAIN from the relay and never reach the gateway. The full Service name mock-llm.kagent.svc.cluster.local resolves to 10.96.178.17 and the gateway returns 200. The short name mock-llm.kagent also resolves to 10.96.178.17, because the search list adds the suffix, and the gateway then returns 403 because the Host header is mock-llm.kagent:8080. THE ACTOR ASKS FOR DNS RELAY ATENET-EGRESS outside host example.com NXDOMAIN no rule lists it not reached no connection is opened Kubernetes API kubernetes.default.svc.cluster.local NXDOMAIN no rule lists it not reached no connection is opened full Service name mock-llm.kagent.svc.cluster.local 10.96.178.17 the rule lists it 200 Host is in the policy short Service name mock-llm.kagent 10.96.178.17 from the search list 403 Forbidden Host mock-llm.kagent:8080
A short Service name passes the DNS relay through the search list, and the gateway then refuses it because the Host header is not in the policy. Read each row from the name to the outcome. The first check is the DNS relay and the second is atenet-egress.

The fix is on your side. Write full names in the policy, and give the agent full names in its configuration, its prompts, and its tool definitions. We did not test a rule for the short name. Full names worked in every run, so use them.

How fast a policy change reaches DNS

The relay keeps the policy of its actor in a cache for 5 seconds. A change to the policy therefore reaches name lookups a few seconds after the API accepts it. We polled a name every 2 seconds and changed the policy during the poll.

outputa poll while a rule for example.com is added, Envoy dataplaneFull log
10:56:33 com=status: NXDOMAIN org=status: NXDOMAIN
10:56:35 com=status: NXDOMAIN org=status: NXDOMAIN
10:56:37 com=status: NXDOMAIN org=status: NXDOMAIN
10:56:39 com=status: NOERROR org=status: NOERROR
10:56:41 com=status: NOERROR org=status: NOERROR
The update returned at 10:56:39.119. The first five of 40 samples.

We measured six policy changes on a second x86 server guest (16 vCPU, 23 GB): one added rule and two removed rules on each dataplane. In five of the six, the first sample with the new answer came within 2 s of the update. In the sixth it came within 3 s. Our poll interval was about 2 s, so we cannot give a finer number. Raw runs

One run did not fit. On the Envoy dataplane we added a rule for example.com, a name that the actor had never queried, and waited 12 seconds. Requests that finished 14 s and 16 s after the update still failed with Could not resolve host. A request at 18 s worked. We saw this delay once and we did not reproduce it, so we cannot name its cause.

Fig. 6.5DNS answers after a policy changetimeline
DNS answers for example.com after a rule for it is added Three tracks over a time axis in seconds, where zero is the moment the policy update returned. In the polled run on Envoy and in the polled run on agentgateway, the samples up to 2 seconds before zero are NXDOMAIN and every later sample is an answer. In a third run on Envoy, with no earlier lookup of the name, requests at 14 and 16 seconds did not resolve the host, and a request at 18 seconds did. update returned Envoy, polled rule added agentgateway, polled rule added Envoy, first lookups of a new name not resolved -4 0 4 8 12 16 20 SECONDS NXDOMAIN or not resolved the relay answered
In our polled runs a new rule reached DNS within 3 seconds, and in one unpolled run on Envoy the name stayed unresolved for 16 seconds. Read each track from left to right. Zero is the moment the update command returned. Rose marks failed lookups and violet marks answers.

Plan for the slow case, because many tools report a DNS failure once and stop. Write the policy before the actor makes its first request. When you add a host to a running actor, wait and retry for 20 seconds before you report a failure.

One behaviour we did not test, and you should know it from the code. When the relay cannot refresh the policy, it keeps the last policy that it read and answers from it. The gateway does the opposite and refuses requests when it cannot read the policy. A removed host can therefore keep its DNS answer during a control plane outage, and the connection still fails at the gateway.

TLS interception and the trust bundle

An https rule makes the gateway end the TLS session with a certificate that it mints, so the actor must trust the projected bundle, and tlsPassthrough leaves TLS intact.

Your agent calls an API over HTTPS, and the policy allows the host. The call fails with a certificate error that you cannot reproduce from your laptop. The host is fine. The actor talked to atenet-egress, which answered with a certificate from a private CA, and the client in the actor does not trust that CA.

When you finish this section, you can choose between an https rule and a tlsPassthrough rule for a host. You can also give an actor the trust bundle, and say where the CA private key lives on each dataplane.

Two rule kinds, two certificates

Both rule kinds allow TLS to a host. They differ in who holds the session.

With an https rule, the gateway ends the TLS session of the actor. It presents a certificate that it created for the requested name, reads each request, and applies the policy to each one. Then it opens its own TLS session to the real host and verifies that host against public roots.

With a tlsPassthrough rule, the gateway reads only the server name in the handshake. If a rule lists that name and port, the gateway forwards the bytes and never decrypts them.

We allowed example.com with https and example.org with tlsPassthrough, and we printed the certificate that the actor received from each.

outputthe certificate for an https rule, Envoy dataplaneFull log
issuer=CN=U7ICLCR3N66DBMBSAQNS5JHTHI
subject=
notBefore=Nov 12 10:48:04 2026 GMT
notAfter=Nov 12 11:08:04 2026 GMT
X509v3 Subject Alternative Name: critical
DNS:example.com
Output of openssl in the actor for example.com. The issuer is the interception CA of the cluster.
outputthe certificate for a tlsPassthrough ruleFull log
issuer=C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
subject=CN=example.org
notBefore=Sep 26 22:05:05 2026 GMT
notAfter=Dec 25 22:13:27 2026 GMT
Output of openssl in the actor for example.org. This is the public certificate of the site.
Fig. 6.6who holds the TLS sessioncomparison
Who holds the TLS session for each rule kind Two columns. On the left, an https rule for example.com: the actor f-main holds TLS session 1 with atenet-egress, which presents a leaf certificate from CN=U7ICLCR3N66DBMBSAQNS5JHTHI, and atenet-egress holds TLS session 2 with example.com. On the right, a tlsPassthrough rule for example.org: the actor holds one TLS session with example.org, atenet-egress reads only the server name and forwards the bytes, and the actor sees a certificate from Cloudflare TLS Issuing ECC CA 3. HTTPS RULE: EXAMPLE.COM TLSPASSTHROUGH RULE: EXAMPLE.ORG actor f-main trusts the projected bundle TLS session 1 atenet-egress leaf from CN=U7ICLCR3N66DBMBSAQNS5JHTHI TLS session 2 example.com public certificate, checked by the gateway actor f-main uses its normal root store one TLS session atenet-egress reads the server name, forwards bytes the same session example.org issuer Cloudflare TLS Issuing ECC CA 3
With an https rule the actor holds a session with atenet-egress, and with a tlsPassthrough rule it holds a session with the real host. Read each column from the actor down to the host. The issuer names are the ones that openssl printed in the actor.
Questionhttps ruletlsPassthrough rule
Certificate that the actor seesminted by the gatewaythe real certificate of the host
Trust that the actor needsthe projected bundleits normal root store
When the policy is checkedfor each requestonce, at the handshake
Credential injectionyesno
Gateway can read the requestyes, headers and bodyno
A client that pins the server certificatefailsworks

Our rule: use https for every host that needs a credential, and for every host where you want a decision for each request. Use tlsPassthrough for a client that pins certificates or brings its own client certificate. Know what you give up with it. The gateway decides once, and the connection then carries whatever the actor sends.

Give the actor the trust bundle

Without the bundle, a client in the actor rejects the minted certificate. With the bundle, the same request works.

terminalone https host with the default store and with the bundle, Envoy dataplaneFull log
$ curl -sS -m 15 -o /dev/null -w 'code=%{http_code} verify=%{ssl_verify_result}\n' https://example.com/
exit=60
code=000 verify=20
…
stderr: curl: (60) SSL certificate problem: unable to get local issuer certificate
…
$ curl -sS -m 15 -o /dev/null -w 'code=%{http_code} verify=%{ssl_verify_result} http=%{http_version}\n' --cacert /run/ate/egress-trust-bundle/trust-bundle.pem https://example.com/
exit=0
code=200 verify=0 http=2
Two curl commands in the actor and their results. The help text that curl prints after the error is cut.

Trust bundle: a file that Substrate projects into the actor, with the interception CA of the cluster and a set of public roots. You ask for it in the ActorTemplate with a systemInfo volume.

yamlthe volume that projects the bundleFull log
volumes:
- name: system-info
  systemInfo:
    dataSources:
    - trustBundle:
        names:
        - egress-mitm.ate.dev
        - system-roots.ate.dev
        path: egress-trust-bundle/trust-bundle.pem
The volumes block of the ActorTemplate that our test actors used. The container mounts system-info at /run/ate.

The first name is the interception CA. The second is the set of public roots, so one file serves intercepted hosts and pass-through hosts. Our bundle held 149 certificates in 222,672 bytes.

Most clients do not read that path on their own. Setting SSL_CERT_FILE to the path was enough for curl in our run. Other runtimes read other variables, such as REQUESTS_CA_BUNDLE for Python requests and NODE_EXTRA_CA_CERTS for Node.js. Set them in the env of the template, not in the agent prompt.

Do not copy the bundle into the container image. The CA can rotate, and the projected file follows the rotation. A copy in an image does not.

The leaf certificate differs between the dataplanes

Both dataplanes sign with the same CA. The certificates that they mint do not look alike.

Field of the minted certificateEnvoy dataplaneagentgateway dataplane
SubjectemptyCN=example.com
Subject alternative nameDNS:example.com, marked criticalDNS:example.com
Valid from5 minutes before the request1 January 1975
Valid until20 minutes after the start1 January 4096
Chain that the gateway sendsthe leaf onlythe leaf and the CA
curl error without the bundleunable to get local issuer certificateself-signed certificate in certificate chain

On Envoy a separate process mints each leaf with the flag --leaf-cert-ttl=15m, and it dates the start 5 minutes into the past. On agentgateway the leaf is valid for 2,121 years.

For the client, both work. For you, the difference changes two things. First, the error text for a missing bundle differs, so search for both strings when you debug. Second, do not write a test that asserts a field of the leaf. A switch of dataplane changes every row of that table.

We prefer the short lifetime of the Envoy leaf. A leaf that stays valid for centuries makes the CA the only control. If the CA key is exposed, a rotation of the CA is the single way to stop a forged certificate.

Where the CA private key lives

The interception CA is the Secret egress-mitm-ca-pool in the namespace ate-system. Whoever holds its private key can present a trusted certificate for any host to every actor that trusts the bundle. So the place where that key is mounted matters more than any field of the leaf.

On the Envoy dataplane, the pod atenet-egress has three containers. The key is mounted into one of them, sdsmint, which serves a Unix socket and no network port. Envoy asks it for certificates over a Unix socket. The process that parses traffic from actors never holds the key.

outputthe container that holds the key, Envoy dataplaneFull log
sdsmint --uds-path=/var/run/sdsmint/sdsmint.sock --ca-pool-path=/run/ca-state/mitm-pool.json --leaf-cert-ttl=15m --log-level=info …
The command of the sdsmint container. The image and the other columns are cut.

On the agentgateway dataplane, the pod has one container, and that container mounts the key.

yamlthe key in the agentgateway configurationFull log
tls:
  mode: dynamicCa
  cert: /run/egress-mitm/tls.crt
  key: /run/egress-mitm/tls.key
Four lines from the listener that intercepts TLS. The volume egress-mitm comes from the Secret egress-mitm-ca-pool.

The same process accepts connections from every actor, parses their requests, and holds the signing key. In our install that container also had no securityContext. The envoy container drops all capabilities and runs as a user other than root.

We do not know of an attack that reads the key through agentgateway. Our point is about the size of the loss if one appears. On Envoy a memory disclosure bug in the proxy exposes traffic. On agentgateway the same class of bug exposes the CA. On either dataplane, limit who can read Secrets in ate-system, because the Secret is the same.

Which upstream certificates the gateway trusts

Interception has a second TLS connection, from the gateway to the real host. The actor trusts the gateway through the bundle. The gateway must trust the host by itself. On both dataplanes it reads the roots from one file in its own container, /etc/ssl/certs/ca-certificates.crt. The agentgateway configuration names that file as backendTLS.root, and the Envoy configuration names it as trusted_ca. Substrate v1.0.0 has no flag and no documented setting for one more upstream CA.

So a host with a certificate from a private CA fails under an https rule. We ran a gateway in the cluster with such a certificate, and an actor called it through an https rule.

We measured this once on each dataplane, on a second x86 server guest (16 vCPU, 23 GB), with 30 requests each. With the default install, 0 of 30 requests succeeded on either one. On agentgateway the actor got 503 with the body upstream connection failed, and the gateway logged invalid peer certificate: UnknownIssuer. On Envoy the actor got 503 with upstream connect error or disconnect/reset before headers. reset reason: remote connection failure. The Envoy log had CERTIFICATE_VERIFY_FAILED. Raw runs

The fix that worked is a mount over the root file. We put the public roots and our CA in one ConfigMap, 122 certificates in all, and mounted it in the proxy container of atenet-egress.

jsonthe patch that adds a private CA to the egress gatewayFull log
[
  {"op": "add", "path": "/spec/template/spec/volumes/-", "value": {"name": "extra-roots", "configMap": {"name": "egress-extra-roots"}}},
  {"op": "add", "path": "/spec/template/spec/containers/0/volumeMounts/-", "value": {"name": "extra-roots", "mountPath": "/etc/ssl/certs/ca-certificates.crt", "subPath": "ca-certificates.crt"}}
]
The complete JSON patch for the Deployment atenet-egress. Container 0 is the proxy container on both dataplanes.

With the patch, the same requests returned 200 on both dataplanes, 30 of 30. The patch is outside the installer, and it does not last. After a later ate-setup deploy ate-system run, the proxy container had no such mount. Apply the patch again after each installer run, and test one request to the private host each time.

The actor never sees the private CA. It sees a leaf for the host name that the interception CA minted, and it verifies that leaf with the projected bundle.

What a new intercepted connection costs

We measured the same private host on the same guest (16 vCPU, 23 GB), with a request of 60 bytes. With a new connection for each request, the median was 618 ms on agentgateway and 614 ms on Envoy, with n=10 each. The TLS handshake with the interception endpoint took 606 and 600 ms of that. On one reused connection, the median was 10.0 and 11.9 ms, with n=30 each. An http rule to a host in the cluster cost 23 to 28 ms for a new connection. Raw runs

So interception is cheap for each request and costly for each connection, about 0.6 s on both dataplanes. A client that opens one HTTPS connection for each call pays it each time. Keep connections open, or use a client with a pool. We did not find which step inside the handshake takes the time. agentgateway with Substrate has the full table.

Credential injection

The gateway replaces a placeholder header with a secret that the actor never holds, and an upstream that returns its request headers gives that secret back to the actor.

An agent needs an API key to call a model or a code host. The agent also runs code that a model wrote. Substrate can copy the memory and the files of its sandbox to an object store at each suspend. You do not want the key in that sandbox, in its environment, or in its snapshots.

When you finish this section, you can write a rule that injects a credential, and grant an atespace access to a Kubernetes Secret. You can also name the cases where the swap fails and the one case where the secret reaches the actor.

The swap in one request

Credential injection is an effect on an http or https rule. The rule names a header and a secret. The actor sends that header with any value, and the gateway overwrites the value before the request leaves the cluster.

Placeholder header: the header that the actor sends with a value that is not a secret, such as Authorization: placeholder-from-actor. Its presence asks for the swap. Its value is thrown away.

This is the rule file from our test. It has three headers, so that one request can exercise each outcome.

yamlone rule with three credential headersFull log
rules:
- http:
    hostnames: ["whoami.egress-target.svc.cluster.local"]
    effects:
      replaceHeaders:
      - header: Authorization
        prefix: "Bearer "
        credentialUri: ate-secret://k8s.io/default/cred-ns/api-token/token
      - header: X-Other-Token
        credentialUri: ate-secret://k8s.io/default/cred-ns-not-granted/api-token/token
      - header: X-Missing-Token
        credentialUri: ate-secret://k8s.io/default/cred-ns/does-not-exist/token
The complete rule file. Only the first header names a Secret that exists in a granted namespace.

Each entry has a header, an optional prefix, and one source: a credentialUri, or an actorJwt that section 5.3 covers. A rule takes up to 16 entries.

The upstream in our test prints each request that it receives. The actor sent the placeholder, and this is what arrived.

terminalthe request that the upstream receivedFull log
$ curl -sS -m 8 -i -H 'Authorization: placeholder-from-actor' http://whoami.egress-target.svc.cluster.local/ | grep -i -E 'HTTP/|authorization|x-forwarded|RemoteAddr|egress'
…
HTTP/1.1 200 OK
RemoteAddr: 10.244.0.22:36554
GET / HTTP/1.1
Host: whoami.egress-target.svc.cluster.local
Authorization: Bearer lab-granted-marker-1794480266
The command in the actor and the matching lines of the response. The upstream is a server that returns the request it received.
Fig. 6.7one request with a placeholder headersequence
One request with a placeholder header Five lifelines: the actor f-main, atenet-egress, the credential provider k8s.io, the Kubernetes Secret cred-ns/api-token, and the upstream host whoami. Six numbered exchanges: the actor sends a request with the header Authorization set to a placeholder, atenet-egress calls FetchSecret with the reference and the actor identity, the provider reads the key token of the Secret, and it returns the secret. atenet-egress sends the request upstream with Authorization set to Bearer and the secret. The upstream answers 200 with a body that repeats the request headers, so the actor reads the secret. actor f-main sends no secret atenet-egress one http rule credential provider k8s.io Secret cred-ns/api-token whoami upstream host 1 Authorization: placeholder GET / through the tunnel 2 FetchSecret reference, actor identity 3 read key token lab-f can use cred-ns 4 the secret 5 Authorization: Bearer <secret> the gateway overwrote the value 6 200, body repeats the request headers the actor reads the secret
The secret travels from the Kubernetes Secret to atenet-egress and on to the upstream, and it enters the actor only if the upstream sends it back. Read from top to bottom. Steps 2 and 3 happen for each request that carries the header. Step 6 is the leak that our test upstream produced.

The environment of the actor held no secret. We listed it, and we searched /run, /etc, and /tmp for the value. Nothing matched. A secret that never enters the sandbox cannot be in a snapshot of it.

Our test used an http rule to a Service in the cluster. With http, the credential leaves the gateway in cleartext. For a real API, put the effect on an https rule.

The ate-secret:// reference and the bundled provider

Credential provider: a gRPC service that turns a reference into a secret value. The gateway picks the provider from the host part of the reference and sends it the reference and the identity of the actor. The provider decides if that actor gets that secret.

Substrate v1.0.0 installs one provider when you pass --credential-provider='{"name":"k8s.io"}'. It runs as the Deployment k8s-credential-provider in ate-system, and it reads Kubernetes Secrets. Its references have this form:

output
ate-secret://k8s.io/default/<namespace>/<secret>/<key>

The provider reads the Secret for each call that it gets and keeps no copy. How soon a rotated Secret takes effect depends on the dataplane, because the gateway decides how often it calls the provider.

DataplaneWhat the gateway doesA rotated Secret takes effect
EnvoyIt calls the provider for each request that carries the header, and it keeps no copy.on the next request, or the one after it: 1.0 to 1.6 s in our run
agentgateway v1.7.0It keeps each fetched credential for 300 seconds, for one actor and one reference. ate-setup sets no other value.after the time that is left of those 300 seconds: 100.6 to 280.5 s in our run

The agentgateway row covers every kagent install, because that distribution runs agentgateway. We read both rows in the code first, and then we measured them.

We measured three changes of one Secret on each dataplane, on a second x86 server guest (16 vCPU, 23 GB). One running actor had one http rule with the effect. It sent one request about each second to an echo service in the cluster. We recorded when that service first saw each new value. Raw runs

We measured these lags in that run on the same guest (16 vCPU, 23 GB), with n=3 changes for each dataplane. On agentgateway the lag was 280.5, 200.9, and 100.6 s. In those times 233, 168, and 84 requests still carried the old value. On Envoy the lag was 1.0, 1.0, and 1.6 s, and one request in all carried an old value. No request failed on either dataplane, in 759 and 275 requests. Raw runs

outputthree changes of the Secret on agentgatewayFull log
rotation	wait_before_s	secret_changed_epoch	first_seen_epoch	lag_s	old_values_after_change
1	5	1794493351.752	1794493632.272	280.5	233
2	100	1794493732.380	1794493933.328	200.9	168
3	200	1794494133.509	1794494234.122	100.6	84
The complete table of the file. The first three columns are the change, the wait before it, and the time of the change. The fourth is the time the upstream first saw the new value. The last two are the lag in seconds and the requests that still carried the old value.
Fig. 6.8seconds until the upstream saw a changed credentialchart
Seconds until the upstream saw a changed credential A chart with a logarithmic time axis. The Kubernetes Secret behind one replaceHeaders rule was changed three times on each dataplane, with one request each second from one actor. On agentgateway the upstream saw the new value after 280.5, 200.9, 100.6 seconds, and 233, 168, 84 requests still carried the old value. On Envoy it took 1.0, 1.0, 1.6 seconds, and 0, 0, 1 requests carried the old value. The three first-seen times on agentgateway are 301 and 301 seconds apart. No request failed on either dataplane, in 759 and 275 requests. 1 s 10 s 100 s 300 s change 1 change 2 change 3 changes of the Kubernetes Secret, in order seconds until the upstream saw the new value, log scale 280.5 s 200.9 s 100.6 s 1.0 s 1.0 s 1.6 s agentgateway old value in 233, 168, 84 requests after the change Envoy old value in 0, 0, 1 requests after the change 300 s new values appeared 301 s apart on agentgateway One request each second. No request failed: 759 on agentgateway and 275 on Envoy.
A changed Secret reached the upstream in 1.0 to 1.6 s on Envoy and in 100.6 to 280.5 s on agentgateway. Read each line left to right over the three changes. The time axis is logarithmic. The solid line is agentgateway, the dashed line is Envoy, and the rose line marks 300 s.

The three lags on agentgateway are not random. The upstream first saw a new value at three moments that were 301 s apart. So the gateway fetched the credential again each time its copy was 300 s old. The lag after a change is the time left until that moment. It can be any value from 0 to about 300 s. On Envoy our probe sent one request each second, so we cannot state a lag below 1 s.

During a change the upstream saw the old value or the new one. It never saw an empty header, and it never saw the placeholder of the actor. That is why no request failed. It also means that a secret which you revoke stays in use on agentgateway for up to 5 minutes. Revoke the old value at the upstream when you must stop its use at once. When you rotate without an incident, keep the old value valid at the upstream for 5 minutes after the change.

We tested one Secret and one actor. We did not test whether a restart of atenet-egress forces a new fetch.

On Envoy, the cost is one call to the provider and one read of the Kubernetes API for each request that carries the header. On agentgateway, most requests make no call to the provider.

The provider starts with no access. A ConfigMap, k8s-credential-provider-namespace-policy, maps each atespace to the namespaces whose Secrets its actors can use. The install creates it empty.

yamlthe grant for one atespaceFull log
policies:
- atespace: lab-f
  allowedNamespaces:
  - cred-ns
The content of the key namespace-policy.yaml in the ConfigMap.

The provider reads this file once, when it starts. We restarted the Deployment after our edit, and the grant worked. The grant is wide: every actor in the atespace can name every Secret in the namespace. Keep one namespace that holds only the Secrets that agents are allowed to use.

The help text of the installer says that a provider requires the Envoy dataplane. In our run the installer accepted k8s.io with agentgateway, and injection gave the same result on both.

When the swap does not happen

The actor sendsThe reference points toEnvoy dataplaneagentgateway dataplane
Authorization with a placeholdera Secret in a granted namespace200, upstream gets the secret200, upstream gets the secret
X-Other-Token with a placeholdera Secret in a namespace with no grant403, egress denied403, actor egress policy denied
X-Missing-Token with a placeholdera Secret that does not exist403, egress denied403, actor egress policy denied
no credential headerany200, request passes unchanged200, request passes unchanged

The two 403 cases return the same body on purpose. A caller cannot use the answer to learn which Secrets exist. You cannot either, so check the grant and the Secret name by hand.

The last row needs care. The effect runs only when the header is present. Without the header, the gateway fetches nothing and forwards the request as it is. The host is still allowed, so the request arrives without a credential.

terminalthe same request without the headerFull log
$ curl -sS -m 8 -i http://whoami.egress-target.svc.cluster.local/ | grep -i -E 'HTTP/|authorization'
…
HTTP/1.1 200 OK
GET / HTTP/1.1
The command and the matching lines. No Authorization line came back, because none was sent upstream.

In practice the symptom is a 401 from the API, not an error from the gateway. A tool that builds its own request, such as a plain wget in a shell, sends no placeholder and gets no credential. We saw this in the kagent run: a wget from the actor reached the model host with three headers and no authorization.

The upstream that returns headers

Look again at the second listing of this section. The actor ran that curl, and the response body contained Authorization: Bearer lab-granted-marker-1794480266. Our test upstream returns each request to its sender, so the actor read the secret that it was never given.

The gateway cannot prevent this. It controls the request, not what the upstream does with it. The consequence is a rule for you: a credential is only as private as the hosts in its rule.

  • Name the exact host that owns the credential. Never attach a credential to a rule with * in its hostnames.
  • Do not attach a credential to a host that the actor can make return its headers, such as a debug endpoint or a request inspector.
  • Give the agent a credential with the smallest scope that the API offers. Treat it as known to the agent.

The logs were clean in our run. The injected value appeared in no log line of the gateway or the provider, on either dataplane. The agentgateway egress pod runs with RUST_LOG=debug and prints the headers of each request. It printed authorization as Sensitive. For an injected X-Api-Key, it printed the placeholder that the actor sent and never the secret. A value that the actor itself sends in a custom header is logged in clear there.

What kagent does with it

kagent uses this feature for model keys, so you get it without writing a rule. For each session, kagent writes a policy with one rule for the model host of the ModelConfig.

yamlthe model rule that kagent wroteFull log
- http:
    effects:
      replaceHeaders:
      - credentialUri: ate-secret://k8s.io/default/kagent/kagent-openai/OPENAI_API_KEY
        header: authorization
        prefix: 'Bearer '
    hostnames:
    - mock-llm.kagent.svc.cluster.local
    ports:
      numbers:
      - 8080
The second of three rules in the policy of one session actor.

kagent also sets OPENAI_API_KEY=kagent-credential-injected in the environment of the actor. The model client reads that variable and sends it as the authorization header, which is the placeholder. Our mock model server received Bearer sk-mock-not-a-real-key, a test value that we made up, on 10 of 10 calls.

We ran kagent against a mock model over HTTP in the cluster. We did not test a real model host over HTTPS. Section 7.1 has the rest of that policy.

The dataplane choice: Envoy or agentgateway

agentgateway does the work of the Envoy dataplane in 38 MB and two containers, but it holds the interception CA key in the process that actors talk to.

The installer has a flag, --atenet-dataplane, with two values and no advice. If you install kagent, the choice is already made for you: it runs agentgateway. Either way you now operate one of two proxies. They do not behave the same when a request is refused or a rule is removed.

When you finish this section, you can choose a dataplane for a cluster and give the reason. You can also say what happens to an open connection after you remove its rule.

One flag changes both gateways

Dataplane: the proxy software inside the pods atenet-router and atenet-egress. The flag --atenet-dataplane takes envoy, the default, or agentgateway. One value applies to both pods.

The Substrate API does not change with the flag. Actors, policies, and kubectl ate work the same. What changes is the code that enforces a policy.

On the Envoy dataplane, two pieces of Substrate code decide. A Substrate process beside Envoy authenticates the actor, decides each HTTP request, and supplies the rules. A Substrate module that Envoy loads into its own process decides TLS connections by server name. On the agentgateway dataplane, policy code inside agentgateway v1.7.0 makes it. Two implementations of one policy explain every difference in this section.

You can switch a running cluster. Run deploy ate-system again with the same flags and the new value.

We measured the switch from Envoy to agentgateway once, on a second x86 server guest (16 vCPU, 23 GB). The command took 32 s and exited with 0. Two running actors stayed in ACTOR_STATE_RUNNING on their workers through the switch. We did not send requests during those 32 seconds, so we cannot say how many would fail. Raw runs

The full comparison

We captured the same inventory and ran the same probes on each dataplane, on one cluster.

Envoy dataplaneagentgateway dataplane
Containers in atenet-routeratenet-router, envoyagentgateway
Containers in atenet-egressenvoy, ext-proc, sdsmintagentgateway
Router listeners8443, 84448443, 8444
Router Service ports443, 8444443, 8444, 80, 8081, 15020
Caller with no client certificateconnection cutTLS alert certificate required
Caller with an identity not on the listTLS alert certificate unknownconnection reset
Caller with the allowed identity200 on 443 and through 8444200 on 443 and through 8444
Unknown actor, or no routing header404, with the reason in the body404, ingress failed
HTTP from an actor with no policyconnection reset403, actor egress policy denied
HTTP to a host outside the policy403, egress denied403, actor egress policy denied
Minted certificate20 minutes, empty subjectyear 1975 to 4096, chain has the CA
Interception CA private keyin sdsmint onlyin the agentgateway container
Container securityContextdrops all capabilitiesnot set
Egress log leveldefaultRUST_LOG=debug
Egress log lines after the same test script1,3696,477
Idle memory, router and egressabout 91 MB in 5 containersabout 38 MB in 2 containers
New request after a rule is removedrefused after 3 to 6 srefused within 1 to 2 s
Fig. 6.9idle memory of each dataplane, container by containerchart
Idle memory of each dataplane, container by container Two stacked bars on an axis from 0 to 100 MB. The Envoy dataplane has 5 containers that hold 91 MB in total: atenet-router and envoy in the pod atenet-router, and envoy, ext-proc, and sdsmint in the pod atenet-egress. Only sdsmint holds the CA key. The agentgateway dataplane has 2 containers that hold 38 MB in total: one agentgateway container in each pod. The agentgateway container in atenet-egress holds the CA key. Each bracket is a pod, and each box is a container. Envoy 5 containers atenet-router atenet-router 25.6 envoy 21.6 atenet-egress envoy 22.8 ext-proc 12.0 sdsmint, 8.9 MB, holds the CA key 91 MB agentgateway 2 containers atenet-router agentgateway 19.9 atenet-egress agentgateway 17.8 holds the CA key 38 MB 0 20 40 60 80 100 MB of memory at idle, one sample for each container
The Envoy dataplane spreads its work over five containers and 91 MB, and agentgateway uses two containers and 38 MB with the CA key inside one. Each bar is one dataplane, each box is one container, and each bracket is one pod. The width of a box is the idle memory of that container in MB, from one sample soon after start. The heavy outline marks the container that holds the CA key.

We measured idle memory with one sample for each container, soon after start, on a second x86 server guest (16 vCPU, 23 GB). The five Envoy dataplane containers held 25.6, 21.6, 22.8, 12.0, and 8.9 MB. The two agentgateway containers held 19.9 and 17.8 MB. In bursts of up to 300 resumes, the Envoy process of the router used 0.12 to 0.19 cores on average. It did not set the rate, as section 4.5 shows. We did not load agentgateway, and we did not compare the two dataplanes under load. Raw runs

Three rows need a comment.

The log row is a volume and a content matter. At debug level, agentgateway prints the headers of each request. In our run it masked authorization and it never printed an injected secret. It did print, in clear, a custom header value that the actor sent. Store those logs with the same care as request headers.

The securityContext row is as the installer wrote it. We did not try to add one to the agentgateway containers.

The last row favours agentgateway, and the next subsection puts it in context.

An open connection outlives its rule

You remove a host from a policy because you want the actor to stop talking to it. The gateway applies the new policy to new work only. This is a known behaviour of v1.0.0, and both dataplanes have it.

Two mechanisms are behind it. The gateway keeps the policy of an actor in a cache, 10 seconds by default on Envoy, so a change takes effect late. And a decision is not revisited: an http or https decision holds for the request, and a tlsPassthrough decision holds for the connection.

We measured each case once for each dataplane, on the second x86 server guest (16 vCPU, 23 GB), with a poll about every 2 seconds. Zero is the moment the update returned. Raw runs

After the rule is removedEnvoy dataplaneagentgateway dataplane
New DNS lookup of the hostNXDOMAIN at the first sample, under 2 sNXDOMAIN within 3 s
New HTTP request to the known IP200 at 1 s and 3 s, 403 from 6 s403 at the first sample, under 2 s
New pass-through connectionrefused at the first sample, under 2 srefused at the first sample, under 2 s
HTTP response that had already startedran to its end, 39 s after removalran to its end, 18 s after removal
Second request on an open pass-through connection, 20 s later200200
Next request on a reused HTTP/1.1 connection of an http rule200 at 6.5 s, 403 from 8.5 s403 from the first request, at 0.8 s
Next request on a reused HTTP/2 connection of an https rule200 at 4.6 s, 403 from 6.6 s403 from the first request, at 0.7 s

We measured the last two rows in a later run on the same guest (16 vCPU, 23 GB), once for each dataplane and each protocol. One curl process in the actor sent 20 requests on one connection, one every 2 s. We removed the rule about 13 s after the start. The start time of each run has a resolution of 1 s, so each time in those rows can be up to 1 s later. Raw runs

Fig. 6.10after a rule is removedtimeline
New work and open connections after a rule is removed Two groups of seven tracks over a time axis from -6 to 40 seconds, where zero is the removal of the rule. Each dot is one sample, about every 2 seconds. On the Envoy dataplane, the first sample after the removal is a refusal for a new DNS lookup and for a new pass-through connection. New HTTP requests are served until 3 seconds and refused from 6 seconds. A request on a reused HTTP/1.1 connection is refused from 9 seconds, and on a reused HTTP/2 connection of an https rule from 7 seconds. An HTTP response that was open ran until the server ended it at 39 seconds, and an open pass-through connection served a second request at 20 seconds. On the agentgateway dataplane, the first sample after the removal is a refusal for all three kinds of new work and for the next request on both reused connections. An open HTTP response ran until the server ended it at 18 seconds, and an open pass-through connection served a second request at 20 seconds. ENVOY DATAPLANE new DNS lookup refused at the first sample new HTTP request refused from 6 s new pass-through connection refused at the first sample reused HTTP/1.1 connection refused from 9 s reused HTTP/2, https rule refused from 7 s open HTTP response the server ended it at 39 s open pass-through second request served at 20 s AGENTGATEWAY DATAPLANE new DNS lookup refused at the first sample new HTTP request refused at the first sample new pass-through connection refused at the first sample reused HTTP/1.1 connection refused at the first sample reused HTTP/2, https rule refused at the first sample open HTTP response the server ended it at 18 s open pass-through second request served at 20 s rule removed 0 10 20 30 40 seconds after the removal served refused bar: a connection opened before the removal
Both dataplanes refused new requests and requests on a reused connection within 9 seconds, and neither closed an open response or pass-through connection. Read each track from left to right, with the removal at zero. Each dot is one sample, green for served and rose for refused. A bar is a connection that was open before the removal and still carried data. The 39 s and 18 s ends are the ends of our test responses, not a limit of the gateway.
outputan open pass-through connection across the removal, Envoy dataplaneFull log
HTTP/1.1 200 OK
Date: Thu, 12 Nov 2026 10:57:50 GMT
HTTP/1.1 200 OK
Date: Thu, 12 Nov 2026 10:58:13 GMT
end=10:58:17
The rule for example.org was removed at 10:57:53. Both responses came on one TLS connection.

The 39 s and the 18 s mark the ends of our test responses. In no run did the gateway end an open connection, so we have no upper limit to give you. An actor that keeps one pass-through connection open keeps its access.

A reused connection is a different case, and it does not keep a removed rule alive. Under an http or an https rule, both dataplanes check the policy for each request. The next request on the open connection got 403, over HTTP/1.1 and over HTTP/2. The delay was the same as for a new request: about 1 s on agentgateway, and 5 to 9 s on Envoy. A new connection after the removal failed earlier, because the name did not resolve.

outputone HTTP/1.1 connection across the removal, agentgateway dataplaneFull log
200 http=1.1 new_conns=0 lport=50509 t=0.012208
200 http=1.1 new_conns=0 lport=50509 t=0.008836
403 http=1.1 new_conns=0 lport=50509 t=0.007677
403 http=1.1 new_conns=0 lport=50509 t=0.009808
…
Requests 6 to 9 of 20 on one connection. The field new_conns=0 and the same local port show that curl reused the connection. The other requests are cut.

So the rule is: a removal stops requests, and it does not stop a response that is streaming or a pass-through connection. Removing a rule is therefore not a way to cut off an actor now. For an http rule, remove the rule and then suspend the actor.

We measured that cut-off once on each dataplane, on the same guest (16 vCPU, 23 GB). The actor held one open HTTP response from a host that writes one chunk each second. An asynchronous suspend actor returned in 0.64 s on agentgateway and in 0.54 s on Envoy. The upstream saw the connection gone 2.2 s and 1.3 s after the suspend call began. It writes once each second, so the real close can be up to 1 s earlier. Raw runs

The connection does not come back. After a resume, no new chunk reached the actor. The curl processes were still in the process list of the actor, restored from memory with sockets that no longer work. An application that you suspend must be able to reconnect. We used an http rule in this test, and we did not repeat it with a tlsPassthrough connection.

Our verdict

Choose Envoy, the default, for a cluster where actors run code that you do not trust and policies have https rules. The reason is the CA key. Envoy keeps it away from the process that parses actor traffic, and section 6.4 explains what that key can do. Envoy also gives better error bodies at the router. It is the only dataplane that accepts the experimental hook for an extra egress filter.

Choose agentgateway in three cases. You run kagent, which installs it. You run small clusters where 53 MB for each cluster matters. Or you already operate agentgateway and want one proxy to know. Section 7.2 covers that last case.

If you choose agentgateway, do three things. Limit who can read Secrets in ate-system and who can open a shell in atenet-egress. Send the egress logs to a store with access rules. And set RUST_LOG=info on the egress Deployment.

We measured that setting with 100 requests at each level, on the same guest (16 vCPU, 23 GB), in one run. At debug, the installer default, the gateway wrote 4,380 lines and 1,590,006 bytes. In 200 of those lines it printed the value of a custom header that the actor sent. At info it wrote 100 lines and 46,300 bytes, one line for each request, and no header value. At warn it wrote no line. All requests returned 200 at every level. Raw runs

bash
kubectl -n ate-system set env deploy/atenet-egress RUST_LOG=info

The command restarts the egress pod. The installer writes debug again at its next run, so the setting is yours to keep. We did not change the level of atenet-router, and we did not check that info logs enough to debug a denied request.

Do not choose on speed. One allowed request took 0.037 s on Envoy and 0.035 s on agentgateway in our probes, and one request each proves nothing about load.

Part 7

Integrations

kagent does the suspending that Substrate leaves to you, and agentgateway can carry the traffic in both directions.

  1. 7.1kagent 1.0 on Substrate
  2. 7.2agentgateway with Substrate
  3. 7.3Substrate on GKE
  4. 7.4When to choose Substrate
  5. 7.5Agent Executor on Substrate

kagent 1.0 on Substrate

kagent makes each Session one actor, resumes it for every turn, and suspends it after, so a turn pays half a second beside the model and keeps only files.

You install kagent 1.0 and send your agent a message. The answer comes back in about a second. Then you run kubectl get pods, and you see one worker pod and no pod for your agent. The agent is an actor, and kagent suspended it again before you looked.

When you finish this section, you can install kagent 1.0, write the three objects that make an agent, and predict what one turn costs. You can also say what a kagent snapshot keeps and which hosts the agent can reach.

We ran kagent v1.0.0. kagent v1.0.1 came out on the day of our run, 2026-11-12, and we did not test it. We ran it twice. In the first run the model was a mock server in the cluster. Those times exclude model time and show the cost of Substrate alone. In the second run the model was a small open model on CPU. The subsection on a turn with a real model reports it, and every other number in this section is from the first run.

The install, as we ran it

The documented CLI path worked with no fix. We used a kind cluster on Kubernetes 1.37 and one command. We found no document of kagent v1.0.0 that requires that Kubernetes version, and we tested no other.

StepSeconds
kind create cluster --name kagent --image kindest/node:v1.37.026.1
download the kagent CLI v1.0.05.5
kagent --config kagent-config.yaml install --namespace kagent216.1

We measured each step once, on a second x86 server guest (16 vCPU, 31 GB). The install command took 216 s and ended with exit code 0. Raw runs

The install command reads one small file.

yamlthe installer configuration that we usedFull log
install:
  kagent:
    valuesFiles: [kagent-values.yaml]
The complete file. The values file that it names holds one setting, grpcReflection true under config.server.

kagent does not use a Substrate that you installed before. It installs its own distribution of Substrate, from images named ghcr.io/kagent-dev/substrate/*:v1.0.0. The install made six Helm releases: kagent-crds, substrate-crds, substrate-podcert, substrate-atelet, substrate, and kagent. In this distribution, atenet-router and atenet-egress each run one agentgateway v1.7.0 container. It has no Envoy.

The Substrate charts are not in the kagent repository. The installer pulls them from the chart registry of the kagent project. The kagent code builds against the kagent copy of the Substrate repository at v1.0.0. The installer also sets four Substrate values that change how the install behaves.

What the kagent installer setsEffect
The preview features PerTemplateSnapshotLocation and GoldenEgressPolicyBoth are on. A plain ate-setup install has them off.
Two bootstrap owners: the identity of the kagent controller, and ate-clientThe kagent controller owns the whole install, as kubectl ate does.
The ingress allowlist of the router: the SPIFFE ID of the kagent controllerOnly the kagent controller can send requests to actors. The atenet-client identity of an ate-setup install is not in the list.
A namespace policy of the credential provider: atespace kagent to namespace kagentActors in the atespace kagent can get Secrets of the namespace kagent.

The install created one WorkerPool, kagent-default, with one worker pod. It created no default Harness. Advice that tells you to select a Harness named kagent-default describes a later build. At v1.0.0 you write the Harness.

The three objects you write

An agent at v1.0.0 is three Kubernetes objects in the group api.kagent.dev/v1alpha3. YAML with the group kagent.dev is older than 1.0 and does not apply.

yamlthe Harness, the AgentTemplate, and the Agent of our runFull log
…
apiVersion: api.kagent.dev/v1alpha3
kind: Harness
metadata:
  name: lab-harness
  namespace: kagent
spec:
  kagent: {}
  substrate:
    workerPoolRef:
      name: kagent-default
    snapshotPolicy:
      location: s3://ate-snapshots/kagent/
---
apiVersion: api.kagent.dev/v1alpha3
kind: AgentTemplate
metadata:
  name: lab-template
  namespace: kagent
spec:
  description: Lab agent that runs shell commands for the Substrate manual experiments
  modelConfig:
    name: mock-model
  systemPrompt: You are a lab agent. Use the bash tool when the user asks you to run a command.
---
apiVersion: api.kagent.dev/v1alpha3
kind: Agent
metadata:
  name: lab-agent
  namespace: kagent
spec:
  template:
    ref:
      name: lab-template
  harnessRef:
    name: lab-harness
The ModelConfig above these three objects is cut. It names the mock model server.

Harness: the runtime that executes the agent, with the WorkerPool and the snapshot location that it uses. kagent: {} selects the engine of kagent. The other types are codex, claude, and byo, and we did not test them.

AgentTemplate: the model, the prompt, and the tools. It has no Substrate field.

Agent: the pair of one AgentTemplate and one Harness. kagent compiles each revision of that pair into one ActorTemplate through the Substrate API. Ours was lab-agent-7f643ac37f02.

The Agent was Ready about 24 s after we applied it. The golden snapshot build used 9.84 s of that time. Substrate has no Actor CRD and no ActorTemplate CRD, so kubectl get shows neither object. Use kubectl ate.

One Session is one Actor

Session: one conversation with an Agent. kagent creates one actor for it, named session-<uuid>, in the atespace kagent. The atespace has the name of the Kubernetes namespace of the Agent.

The actor is created in ACTOR_STATE_SUSPENDED, and kagent does not resume it. The first message does. A Session create took 0.39 s at the median over 20 creates.

Fig. 7.1one recorded kagent turn, from the message to the answersequence
One recorded kagent turn resumes the Session actor, runs, and suspends it again Five lifelines, each under a small drawing of its party: the client as a terminal, the kagent controller as a server with the kagent mark, atenet-router as a gateway with the agentgateway mark, ate-api-server as a server with the Agent Substrate mark, and the actor named session-uuid as three layers. Nine numbered exchanges show one recorded turn, each with its time from the moment the client sent the message. The router asks for ResumeActor and the actor is RUNNING 0.41 s later. The turn runs. kagent calls SuspendActor, the client has its answer after 1.26 s, and the actor is SUSPENDED after 1.36 s. TIME client kagent CLI kagent controller :8083 atenet-router agentgateway ate-api-server ate-system actor session-<uuid> 1 +0.00 s message A2A, with the Session id 2 +0.32 s ate-target-actor mTLS, port 443 3 +0.47 s ResumeActor the router asks 4 +0.88 s RUNNING resumed in 0.41 s 5 the turn gRPC to port 80 of the actor 6 +1.07 s TASK_STATE_COMPLETED the turn ran 0.19 s 7 +1.23 s SuspendActor synchronous, files only 8 +1.26 s answer 1.26 s at the client 9 +1.36 s SUSPENDED suspended in 0.13 s One recorded turn of 98. Over all of them the resume took 0.380 s at the median, and the suspend took 0.142 s. The model was a mock server.
Every turn resumes the Session actor through the router, and kagent calls SuspendActor as soon as the task completes. Read top to bottom. The drawing above each lifeline is its party, with the mark of its project. The left column is the time of each step in one recorded turn, counted from the message. Amber arrows are state changes. The model was a mock server.

The log of ate-api-server shows the cycle. These lines are one Session: the create, then two turns that were 4 s apart.

outputone Session in the state log, create and two turnsFull log
2026-11-12T10:20:35.633350007Z	session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1	create	suspended
2026-11-12T10:20:36.067645235Z	session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1	resume	resuming
2026-11-12T10:20:36.485975028Z	session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1	resume	running
2026-11-12T10:20:36.862693911Z	session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1	suspend	suspending
2026-11-12T10:20:36.995534634Z	session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1	suspend	suspended
2026-11-12T10:20:41.387075902Z	session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1	resume	resuming
2026-11-12T10:20:41.796382831Z	session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1	resume	running
2026-11-12T10:20:42.143125306Z	session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1	suspend	suspending
2026-11-12T10:20:42.270402046Z	session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1	suspend	suspended
The columns are time, actor, operation, and new state.

All 98 turns of our run had this shape: resume, run, suspend. Substrate does not suspend an idle actor, as SuspendActor and ResumeActor shows. kagent does that work for you, after every turn.

What one turn costs

Fig. 7.2every resume, suspend, and turn of the kagent runchart
A kagent turn costs the same with a pause and with no pause Every run of our kagent test with a mock model, as one dot each, with the median as a line. Resume 0.380 s over 98 turns, suspend 0.142 s over 99 suspends, a turn 4 seconds after the last one 0.726 s, a turn with no pause 0.738 s, and the first turn of a new Session 0.605 s. 0 0.25 0.5 0.75 1 seconds for each run, with a mock model STATE CHANGES INSIDE A TURN THE WHOLE TURN, IN THE CLUSTER 0.380 s resume 98 runs 0.142 s suspend 99 runs 0.726 s turn, 4 s apart 20 runs 0.738 s turn, no pause 20 runs 0.605 s first turn 10 runs no warm turn: 0.012 s apart one dot: one run, sorted from the fastest to the slowest. black line: the median.
A turn that follows the last one with no pause costs the same as a turn 4 s later, because every turn resumes the actor. Each dot is one run, and each column is sorted from the fastest run to the slowest. The black line is the median. Amber columns are the two state changes inside a turn. Plum columns are the whole turn as kagent reports it.

We measured 98 turns on the same second x86 server guest (16 vCPU, 31 GB), with a mock model. The resume took 0.380 s at the median, from 0.216 to 0.542 s. The suspend took 0.142 s at the median over 99 suspends, from 0.120 to 0.398 s. A whole turn took 0.726 s in the cluster and 1.224 s at the client, over 20 turns spaced 4 s apart. Raw runs

No warm turn exists. Twenty turns with no pause took 0.738 s at the median, against 0.726 s for the spaced turns. The uptime inside the sandbox read 0.6 to 0.9 s on every turn. So you pay about 0.52 s of resume and suspend on each turn, and a real model adds its own time to that. The next subsection measures both.

A files-only resume is this fast only because the kagent harness starts fast. Every such resume is a new start of the image. We ran a Python agent at the same fidelity on the same guest, on the cluster of the Part 9 demos. It needed 4.5 to 5.1 s for each turn after a suspend. Snapshot fidelity compares the cases.

One of the 98 turns, turn 3 of the first session, took 6.56 s, with a gap of 5.5 s before the resume. We did not find the cause.

A turn with a real model

The mock model answers at once, so it hides the largest part of a real turn. We ran the same cluster again with a real model. It was qwen2.5, an open model, in two sizes. The model server was Ollama 0.40.16 in a container on the guest, with 12 CPUs, one request at a time, and no GPU.

ModelSizeTokens per second, 5 requestsCorrect tool call, 10 requests
qwen2.5:3b1.9 GBmedian 7.1, from 6.9 to 7.310 of 10
qwen2.5:1.5b986 MBmedian 13.9, from 13.1 to 14.39 of 10

This is a small model on slow hardware, and we chose it because both graphics cards of the server were in use by another team. A model on a GPU or a hosted model gives other model times. Read the model seconds below as the time of our machine. The Substrate seconds do not depend on the model.

Each turn asked the agent to run one command with its bash tool and to report one number. So a turn is two model requests: one that returns the tool call, and one that reads the result.

Fig. 7.3every kagent turn with a small real model on CPUchart
Every kagent turn with a small real model on CPU All 50 turns of the kagent run with a small open model on CPU, one bar each, in the order of the run. The plum part of a bar is the time of the model requests and the amber part is the rest of the turn, which holds the resume and the suspend. With the 3b model, a turn in one session took a median of 17.1 s and the model was 93 percent of it. With the 1.5b model the median was 11.0 s. 48 turns were clean. One turn with the 1.5b model did not end: the client gave up after 300 seconds, and the next turn waited 190 seconds behind it. 0 5 10 15 20 seconds for each turn at the client MODEL REQUESTS THE REST OF THE TURN NO ANSWER, OR WAITED 17.1 s 3b, one session 20 of 20 clean 14.4 s 3b, new 5 of 5 clean 11.0 s 1.5b, one session 20 of 20 clean 1.5b, new 3 of 5 clean 300 s with no answer, then 190 s one bar: one turn, in run order. black line: the median of a series with no failed turn. resume plus suspend: about 0.7 s of each turn. qwen2.5 on 12 CPUs, no GPU.
The model was about nine tenths of every clean turn, and one turn of 50 did not end. Each bar is one turn, in the order of the run. The plum part is the time of the model requests of the turn. The amber part is the rest, which holds the resume and the suspend. A rose bar is a turn that did not end, or a turn that waited behind it. Both are cut at the top of the chart.

We measured 50 turns on the same second x86 server guest (16 vCPU, 31 GB). Each model ran 20 turns in one Session and the first turn of 5 new Sessions. Raw runs

SeriesTurnsCompletedTurn at the clientModel requestsResumeSuspendModel share
3b, one Session2020median 17.1 s, from 11.8 to 19.215.9 s0.46 s0.24 s0.93
3b, first turn of a new Session55median 14.4 s, from 13.4 to 16.413.0 s0.45 s0.17 s0.92
1.5b, one Session2020median 11.0 s, from 10.0 to 19.79.8 s0.47 s0.23 s0.89
1.5b, first turn of a new Session5412.2, 13.2, and 13.4 s for the three clean turns7.2 to 12.3 s0.46 s0.16 ssee below

Every one of the 49 completed turns again had the shape resume, run, suspend. The resume and the suspend took about 0.7 s together, as with the mock model. The turn grew from 1.2 s at the client to 11 to 17 s, and the model was the difference. The resume and suspend columns are from the log of ate-api-server. For the last turn of each long series, the saved log held no resume record, so those two medians have 19 values.

Three turns of the 1.5b series sent three model requests in place of two. Two more turns of that series had about 5 s that neither the model nor Substrate accounts for. We did not find the cause.

One turn did not end

In the third new Session of the 1.5b series, the model did not stop. It had produced 2,800 tokens when we restarted the model server, 7 minutes and 40 s after the request began. The kagent CLI gave up after 300.13 s with context deadline exceeded and exit code 1.

Nothing ended the turn before the client did. The Session actor was ACTOR_STATE_RUNNING 193 s into the turn, and it held its worker CPU for all that time. The next turn waited behind the same model request, because the server took one request at a time. That turn completed after 189.8 s.

This is one event with one small model, and a larger model is less likely to run away. The cost of the event does not depend on the model. An actor that waits for a model is RUNNING, and kagent suspends it only when the turn completes. Nothing bounds a turn by default. Two settings of the ModelConfig do, and we tested both in a later run.

Two settings that bound a turn

The ModelConfig of kagent v1.0.0 has three fields for this under spec.openAI: maxTokens, maxCompletionTokens, and timeout. The timeout is in seconds, and it is the timeout of the HTTP client that calls the model. The two token fields exclude each other. The Agent, the AgentTemplate, and the Harness have no field for the seconds of a turn or for the number of model calls. The kagent CLI has --timeout, with a default of 5 minutes.

yamltwo ModelConfig objects, one with each boundFull log
apiVersion: api.kagent.dev/v1alpha3
kind: ModelConfig
metadata: {name: w-tok, namespace: kagent}
spec:
  provider: OpenAI
  model: qwen2.5:1.5b
  apiKeySecret: kagent-openai
  apiKeySecretKey: OPENAI_API_KEY
  stream: true
  openAI:
    baseUrl: http://ollama.kagent.svc.cluster.local:11434/v1
    maxTokens: 128
…
apiVersion: api.kagent.dev/v1alpha3
kind: ModelConfig
metadata: {name: w-time, namespace: kagent}
spec:
  provider: OpenAI
  model: qwen2.5:1.5b
  apiKeySecret: kagent-openai
  apiKeySecretKey: OPENAI_API_KEY
  stream: true
  openAI:
    baseUrl: http://ollama.kagent.svc.cluster.local:11434/v1
    timeout: 20
The AgentTemplate and Agent objects that use each ModelConfig are cut.

We first tried to get the runaway back, and it did not come. The first turns of 10 new Sessions on the 1.5b agent with no bound all completed, in 5.9 to 11.5 s. So we forced a long answer with a prompt that asks for the numbers 1 to 3000, one on each line. Sent to the model server directly, that prompt was still producing text when our request ended at 75 s. An earlier prompt asked for one word 5000 times, and the model stopped by itself in 7 of 7 turns.

We measured each row below on the same second x86 server guest (16 vCPU, 31 GB), after a restart of the guest. Every turn was the first turn of a new Session with the count prompt and the 1.5b model. Raw runs

SettingTurnsResultTurn at the clientThe actor and the model afterwards
maxTokens: 1283TASK_STATE_COMPLETED, with 128 output tokens and an answer that ends at 4615.6, 19.0, and 19.4 sACTOR_STATE_SUSPENDED at the end of each turn
timeout: 203TASK_STATE_FAILED, and the CLI ended with exit code 120.8, 20.9, and 21.2 sACTOR_STATE_SUSPENDED 5 s later, with the model server at 0% CPU
no bound, and --timeout 60s at the CLI1the CLI ended at 60.12 s with context deadline exceedednot endedACTOR_STATE_RUNNING in all 8 samples over 240 s, with the model server at about 1,200% CPU

With timeout: 20, the task failed with this text: llm error response (code STREAM_ERROR): "context deadline exceeded (Client.Timeout or context cancellation while reading body)". The model server logged each of those three requests at 19.9 s. The Session was still usable. The next turn in one of those Sessions completed in 17.1 s, in one try.

Neither bound broke a normal tool turn. With maxTokens: 128, 5 of 5 tool turns completed, in 5.5 to 8.0 s. With timeout: 20, 5 of 5 completed, in 5.8 to 12.1 s. On our slow model server, a tool turn of 12.1 s is close to a bound of 20 s. Choose the seconds from the speed of your model.

The row with no bound shows what a client timeout does not do. After the CLI gave up, the model request ran on for 10 minutes and 10 s, until we restarted the model server. A turn on a new Session waited behind it and reached the CLI default at 300.13 s. A second turn on the first Session returned resource conflict after 0.13 s. The timeout of the client stops the client. It does not stop the model call, and it does not suspend the actor.

We did not see a runaway happen by itself while a bound was set, and we did not test maxCompletionTokens.

Our position: set timeout on every ModelConfig, because it also ends a model call that stalls. Add maxTokens when a cut answer is better for your users than a failed turn. Do not count the client timeout as a bound. Then count how many Sessions can wait for the model at one time, because each one holds 1 CPU of a worker. The subsection on parallel turns below has that arithmetic.

What the snapshot keeps

kagent asks Substrate for the files-only fidelity, SNAPSHOT_FIDELITY_VOLUMES, and it suspends in the synchronous mode. The snapshot is in the object store before the turn ends. It holds the directory /data and nothing else.

In turn 1 our agent wrote one file under /data, wrote one file under /tmp, and started a background sleep. Turn 2 looked for all three.

outputwhat turn 2 found after one suspend and one resumeFull log
note=hello-from-turn1
scratch=cat: can't open '/tmp/scratch.txt': No such file or directory
bg=
uptime=0.74
PID   ELAPSED COMMAND
    1  0:01   /app
…
The rest of the output, the environment and the mounts, is cut.

The file under /data survived. The file under /tmp and the background process did not, and PID 1 was a new process. Write to /data everything that the next turn needs. Snapshot fidelity compares this mode with a memory snapshot.

The three egress rules that kagent writes

kagent writes one egress policy for each actor through the Substrate API. Our cluster had no Kubernetes EgressPolicy object at all.

yamlthe egress policy of one Session actorFull log
…
rules:
- http:
    effects:
      replaceHeaders:
      - actorJwt:
          audiences:
          - kagent-runtime
          expirationSeconds: "300"
        header: authorization
        prefix: 'Bearer '
    hostnames:
    - kagent-controller.kagent.svc.cluster.local
    ports:
      numbers:
      - 8083
- http:
    effects:
      replaceHeaders:
      - credentialUri: ate-secret://k8s.io/default/kagent/kagent-openai/OPENAI_API_KEY
        header: authorization
        prefix: 'Bearer '
    hostnames:
    - mock-llm.kagent.svc.cluster.local
    ports:
      numbers:
      - 8080
- https:
    hostnames:
    - api.openai.com
    ports:
      numbers:
      - 443
The CLI warning and the metadata above the rules are cut.
  1. The kagent controller on port 8083. The gateway replaces the authorization header with an Actor JWT for the audience kagent-runtime.
  2. The model host of the ModelConfig. The gateway replaces the authorization header with the value of a Kubernetes Secret. Credential injection explains the mechanism.
  3. api.openai.com on port 443. kagent adds this rule for every OpenAI ModelConfig, also when the model is at another host.

The actor never had the key. Its environment held OPENAI_API_KEY=kagent-credential-injected, and the mock model received the real value on all 10 calls.

A rotated key is slower here than on Envoy. The gateway, agentgateway v1.7.0, keeps each fetched credential for 300 seconds. So a new value of the Secret can take up to 5 minutes to reach the model calls. We measured that delay on the agentgateway dataplane of upstream Substrate, and not in the kagent cluster. Three changes of a Secret reached the upstream after 280.5, 200.9, and 100.6 s, and no request failed. Credential injection has that run.

Everything else was denied. A request to example.com failed in DNS. A request to the IP address 1.1.1.1 returned 403. The short name mock-llm.kagent also returned 403, and only the full name mock-llm.kagent.svc.cluster.local returned 200. Give kagent full DNS names for model and tool hosts.

Parallel turns on one worker

The worker pod of the install has no resource limits, so it reports the whole node: 16,000 millicores. Each actor reserves 1 CPU. The worker therefore ran at most 16 actors at one time.

We measured this on the same x86 server guest with one worker. Eight turns that started together all completed, in 2.47 to 3.57 s. Twenty-four turns that started together all completed, in 4.25 to 7.85 s. The worker showed 16 running actors, and the other 8 turns waited. Sizing a pool gives the arithmetic. Raw runs

Fig. 7.48 and 24 kagent turns at one time on one workerchart
Parallel kagent turns on one worker that holds 16 actors Seconds at the client for every turn of two tests on one worker pod, sorted from the fastest to the slowest. Eight turns that started together took 2.47 to 3.57 seconds. Of 24 turns that started together, the first 16 took 4.25 to 5.45 seconds and the last 8 took 6.45 to 7.85 seconds, because the worker ran at most 16 actors. One turn alone took 1.22 seconds. 0 2 4 6 8 1 8 8 turns at one time seconds at the client, for each turn 0 2 4 6 8 1 8 16 24 24 turns at one time, sorted by time 16 actors run: 16 of 16 CPU reserved 8 turns wait one turn alone: 1.22 s 3.57 s 5.45 s 7.85 s one dot: one turn, with a mock model. All 32 turns completed.
Sixteen of 24 parallel turns ended within 5.45 s, and the other eight waited for a free CPU and ended up to 2.4 s later. Each dot is one turn, sorted by its time at the client. The violet band holds the turns that waited while the worker ran 16 actors. The dashed line is one turn alone.

The CLI port-forward collision

The kagent CLI opens its own port-forward to port 8083 for each call. Five of eight CLI calls that we started together failed.

outputa kagent CLI call that lost the portFull log
Error: connect to kagent gateway: kubectl port-forward exited before the server became ready (kubectl: Unable to listen on port 8083: … bind: address already in use …
The second listener error and two more lines are cut.

Start one kubectl port-forward to the controller yourself, and point every CLI call at it. With a standing port-forward, a Session create took 0.17 s at the median in place of 0.39 s.

agentgateway with Substrate

agentgateway can be the dataplane of Substrate, the dataplane inside the kagent distribution, or a gateway behind an egress rule, and only the third selects models and tools.

A colleague tells you that all agent traffic goes through agentgateway, so you can limit which models an agent calls. The first half is true in some clusters. The second half depends on where agentgateway is in the path. It can be in three places, and each place decides different things.

When you finish this section, you can name the place that agentgateway has in a cluster. You can also say what the egress policy of Substrate can express, and when you need a second gateway behind it.

Fig. 7.5three places for agentgatewayflow
Three places where agentgateway can sit next to Substrate Three rows. Row A: agentgateway is the proxy in atenet-router and atenet-egress when you install Substrate with the agentgateway dataplane. Row B: the kagent distribution installs the same two proxies and has no Envoy option. Row C: a separate agentgateway is a model gateway that the actor reaches through one egress rule, and that gateway selects the model. In our run with two mock backends, a request on a new intercepted TLS connection took 618 milliseconds on the agentgateway dataplane and 614 on Envoy, and a request on a reused connection took 10 and 12 milliseconds. A · THE DATAPLANE OF SUBSTRATE, BY YOUR CHOICE client mTLS client atenet-router agentgateway actor my-counter-1 atenet-egress agentgateway host in the policy decides: who gets in, which hosts an actor reaches, one injected header ate-setup --atenet-dataplane=agentgateway. Idle: 38 MB in 2 containers. Envoy: 91 MB in 5. B · THE DATAPLANE INSIDE THE KAGENT DISTRIBUTION kagent controller atenet-router agentgateway actor session-<uuid> atenet-egress agentgateway model host in the policy decides: the same three things The kagent charts install agentgateway v1.7.0 and have no Envoy option. 98 turns ran through it. C · A GATEWAY THAT THE ACTOR CALLS THROUGH AN EGRESS RULE actor f-main atenet-egress one https rule agentgateway model gateway model mock-a, mock-b decides: the model, and the key that the provider gets Ran over TLS with two mock models. A new connection: 618 ms. A reused one: 10 ms.
In places A and B agentgateway enforces the Substrate policy, and only in place C does it read model and tool traffic. Each row is one request path, read left to right. Indigo boxes are the Substrate dataplane. The bold line under a row says what agentgateway decides in that place. The dashed box is a gateway that you deploy yourself.
PlaceWho installs itWhat it decidesWhat we ran
A. The dataplane of Substrateyou, with one ate-setup flagingress identity, the egress policy, credential injectionthe full comparison with Envoy
B. The dataplane in the kagent distributionthe kagent installerthe same as A98 kagent turns through it
C. A model or MCP gateway behind egressyou, as your own workloadmodels, tools, budgetsa model gateway with two mock models, over TLS, on both dataplanes

Place A: the dataplane of Substrate

Substrate has two proxies in every request path: atenet-router for ingress and atenet-egress for egress. The proxy binary in both is Envoy by default. Pass --atenet-dataplane=agentgateway to ate-setup, and both run agentgateway.

We switched a running cluster from Envoy to agentgateway with that flag. The command took 32 s and ended with exit code 0. We did not need a new cluster.

We measured both dataplanes on an x86 server guest with 16 vCPU and 23 GB, one sample each at idle. The router and the egress gateway used about 38 MB in 2 containers with agentgateway. They used about 91 MB in 5 containers with Envoy. Raw runs

Our short verdict has four parts.

  • Lighter. Two containers do the work of five, with less than half the idle memory.
  • Ingress clients are authenticated. A client with no certificate got the TLS alert certificate required. A client with an allowed identity got 200.
  • The interception CA private key is in the dataplane container. With Envoy, only a sidecar holds that key.
  • The egress gateway logs at debug level. It prints the headers of every request. An injected credential never appeared in the log. A custom header that the actor set itself did appear, in clear text.

Choose agentgateway when memory on each node matters to you. Before production, lower the log level, and decide if you accept the key in the proxy container. The dataplane choice has the full comparison, row by row.

The log level is one variable on the egress Deployment, and the installer sets it to debug.

bash
kubectl -n ate-system set env deploy/atenet-egress RUST_LOG=info

We measured 100 requests from one actor at each level, on a second x86 server guest (16 vCPU, 23 GB), in one run. Each request had a custom header and an Authorization header with test values. At debug the gateway wrote 4,380 lines and 1,590,006 bytes, and 200 lines held the value of the custom header. At info it wrote 100 lines and 46,300 bytes, and no line held a header value. At warn it wrote nothing. All 300 requests returned 200. Raw runs

The change does not last. After the next ate-setup deploy ate-system run, the Deployment had RUST_LOG=debug again. Set it after each installer run, or in the tool that applies your manifests.

Place B: the dataplane inside the kagent distribution

kagent installs its own distribution of Substrate, as kagent 1.0 on Substrate shows. In that distribution agentgateway was the only dataplane that we saw. Our cluster ran agentgateway v1.7.0 in atenet-router and in atenet-egress. The values that the kagent installer passes to the Substrate charts name no dataplane. The charts themselves are not in the kagent repository, so we could not check them for an Envoy option.

One cost of this dataplane is specific to credentials. The gateway, agentgateway v1.7.0, keeps each fetched credential for 300 seconds. So a rotated Secret can take up to 5 minutes to reach requests. With Envoy and the bundled provider, the next request gets the new value.

We measured this in place A, on a second x86 server guest (16 vCPU, 23 GB), with three changes of one Secret on each dataplane. On agentgateway the upstream saw the new value after 280.5, 200.9, and 100.6 s. On Envoy it took 1.0, 1.0, and 1.6 s. No request failed on either one. The code is the same in place B, so plan for the same 5 minutes there. Credential injection has the run. Raw runs

We saw the same two costs as in place A. The egress gateway ran with RUST_LOG=debug, and 3,376 of its 4,199 log lines in 3 minutes were debug lines. The CA private key was mounted in its container.

We also saw the same ingress authentication. A plain pod with no certificate could not reach an actor.

outputthe router of the kagent distribution refuses a pod with no certificateFull log
from namespace default:
http://atenet-router.ate-system.svc:80/ code=000 curl_exit=7 Failed to connect to atenet-router.ate-system.svc port 80 after 3 ms: Could not connect to server
http://atenet-router.ate-system.svc:8081/ code=000 curl_exit=7 Failed to connect to atenet-router.ate-system.svc port 8081 after 2 ms: Could not connect to server
https://atenet-router.ate-system.svc:443/ code=000 curl_exit=56 OpenSSL SSL_read: OpenSSL/3.3.2: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
https://atenet-router.ate-system.svc:8444/ code=000 curl_exit=56 OpenSSL SSL_read: OpenSSL/3.3.2: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
…
The probe ran from a pod in the default namespace. Three more lines of this namespace are cut.

Ports 80 and 8081 refused the connection. Ports 443 and 8444 asked for a client certificate. The Session actor stayed ACTOR_STATE_SUSPENDED through the probe.

kagent itself has no agentgateway configuration. kagent writes an egress policy for each actor into the Substrate API, and the dataplane enforces that policy. So place B gives kagent the same policy that place A gives you, and no more.

Place C: a gateway that actors call

You will read that a policy can say which models, tools, or other agents an actor can call. At v1.0.0, the egress policy of Substrate has no field for any of them. This table is the whole policy.

The egress policy canThe egress policy cannot
allow a host name, exact or with one wildcard labelallow or deny a model name
allow a port on that hostallow or deny one tool of an MCP server
choose http, https with interception, or tlsPassthroughset a token budget or a rate limit
replace one request header with a Secret value or an Actor JWTinspect a prompt or an answer

The configuration of the egress gateway shows the same limit. Each route has one policy, substrateEgress, and one kind of backend, dynamic. A dynamic backend is the host that the actor asked for. The file has no model backend and no MCP route.

yamlthe request policy of the agentgateway egress gatewayFull log
…
        - backends:
          - dynamic: {}
            policies:
              backendTLS:
                root: /etc/ssl/certs/ca-certificates.crt
          policies:
            substrateEgress: &egressPolicy
…
The listeners above this route and the credential provider settings below it are cut.

agentgateway as a product can do the rest. Its model gateway mode routes requests to model providers and applies budgets, load balancing, and failover. Its MCP gateway mode federates tools from MCP servers. Neither mode is configured in the Substrate dataplane, in place A or in place B.

To use those modes, deploy a second agentgateway as your own workload and put it behind the egress gateway:

  1. Run agentgateway as a model gateway or an MCP gateway in the cluster.
  2. Write one egress rule that allows only the host and the port of that gateway.
  3. Add a replaceHeaders effect with an Actor JWT to that rule, so the gateway knows which actor calls.
  4. Make the gateway verify that token against the issuer of Substrate, and write the model and tool rules there.

The actor can then reach no model host directly, and the second gateway decides what each actor is allowed to call.

What we ran

We ran the first two steps of this design, with a model gateway and no real model. The gateway was agentgateway v1.7.0 as a normal Deployment in its own namespace. Two mock backends with an OpenAI-compatible API stood for two providers, and each one logged what it received. The gateway served TLS with a certificate from a private CA that we made with openssl.

yamlthe configuration of the model gatewayFull log
config: {}
llm:
  port: 8443
  tls:
    cert: /tls/tls.crt
    key: /tls/tls.key
  models:
  - name: fast
    provider: openAI
    params:
      model: small-1
      hostOverride: mock-a.model-gw.svc.cluster.local:80
      apiKey: $KEY_A
  - name: smart
    provider: openAI
    params:
      model: big-1
      hostOverride: mock-b.model-gw.svc.cluster.local:80
      apiKey: $KEY_B
The complete file. The two key variables hold test values that we made up.

The actor had one https rule for the gateway host. The rule replaced the header X-Lab with the value of a Kubernetes Secret. The actor sent a placeholder in that header and in Authorization, and it asked for the model fast.

terminalone request from the actor through egress to the model gatewayFull log
$ curl -sS -m 15 --cacert /run/ate/egress-trust-bundle/trust-bundle.pem -w '\ncode=%{http_code} t=%{time_total} verify=%{ssl_verify_result}\n' -H 'content-type: application/json' -H 'Authorization: Bearer placeholder-from-actor' -H 'X-Lab: placeholder-from-actor' -d '{"model":"fast","messages":[{"role":"user","content":"hi"}]}' https://modelgw.model-gw.svc.cluster.local/v1/chat/completions
exit=0
{"model":"small-1","usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2},"choices":[{"message":{"content":"served_by=backend-a model=small-1 authorization=Bearer provider-key-for-backend-a x_lab=lab-gw-client-credential-1794488723","role":"assistant"},"index":0,"finish_reason":"stop"}],"id":"chatcmpl-lab","object":"chat.completion","created":1794488852}
code=200 t=0.744791 verify=0
…
{"error":{"message":"Model not found","type":"invalid_request_error","code":"model_not_found"}}
code=404
One request on the agentgateway dataplane, and the answer for a model name that the gateway does not know. The requests between them are cut. All values are test values that we made up.

We measured this path on a second x86 server guest (16 vCPU, 23 GB), once on each dataplane. The backend mock-a received the model small-1, the provider key of the gateway, and the Secret value in X-Lab. With "model":"smart", the backend mock-b received big-1 and the second provider key. The placeholder of the actor reached no backend, in 0 log lines. A request for "model":"other" returned 404 with model_not_found from the model gateway. Raw runs

So the model gateway selects the model, and the egress policy does not. Two keys are in the path, and the actor sends neither. Our mock backend printed both in its reply, so the actor read them there. A real backend must not return a credential.

The egress gateway injects the header that identifies the caller. The model gateway replaces Authorization with its own provider key. A credential for the model gateway itself must therefore travel in another header. We did not configure client authentication on the model gateway, and we did not use an Actor JWT here.

The private CA failed at first. With the default install, the request through the https rule returned 503 on both dataplanes. The egress gateway verifies the upstream certificate against the system roots of its own container, and it did not know our CA. TLS interception has the two error texts and the patch that fixed it. The patch is lost when the installer runs again.

We measured the latency on the same guest (16 vCPU, 23 GB), as medians of n=30 or n=10. The request had 60 bytes, and the reply had about 250 bytes. Raw runs

PathA new connection for each requestOne reused connection
A plain pod to a mock backend, no egress4.2 to 4.4 msnot measured
A plain pod through the TLS model gateway14.7 to 14.9 msnot measured
The actor through egress to a mock backend, http rule22.8 to 27.5 ms9.2 ms on agentgateway, 4.4 ms on Envoy
The actor through egress, https rule, to the TLS model gateway618 and 690 ms on agentgateway, 614 ms on Envoy10.0 ms on agentgateway, 11.9 ms on Envoy

The last row is the one to design for. Each new connection under an https rule cost about 0.6 s on both dataplanes. Almost all of it was the TLS handshake with the interception endpoint: 606 of 618 ms on agentgateway, and 600 of 614 ms on Envoy. On a reused connection the same request took 10 to 12 ms. An agent that opens a new HTTPS connection for each model call pays 0.6 s each time. Keep one client with a connection pool for the life of the actor.

A second cluster ran node tests on the same guest at the same time, so read a difference of a few milliseconds as noise. We did not find where the 0.6 s goes inside the handshake. We did not test a real model host, a streamed reply, or an MCP gateway.

Three limits of the egress gateway apply to this design, and one section proves each:

  • The gateway replaces a header only when the request already has that header. A request with no authorization header passed unchanged. Credential injection has that run.
  • A WebSocket upgrade returned 403, so the gateway behind egress must use plain HTTP requests or streamed HTTP responses. EgressPolicy and default deny has that run.
  • A removed rule does not close a response that is streaming or a pass-through connection. A reused HTTP connection is refused at its next request. The dataplane choice has those runs.

Substrate on GKE

Agent Substrate on GKE is an installer and its documentation for a system that you operate in your own GKE Standard cluster, and Google does not run its control plane.

Your team runs GKE, and you read that Agent Substrate is generally available there. You open the cluster page and look for a checkbox. You find none, and gcloud has no command for it. We did not run Substrate on GKE. This section comes from the Google documentation and the installer code, both read on 2026-11-12, and it says so where that matters.

When you finish this section, you can say what the GKE installer creates and what it leaves for you to operate. You can also check a cluster against the requirements and find the quota that limits your resume rate.

What "on GKE" means

Agent Substrate on GKE is open source software that you deploy onto your own GKE Standard cluster. Google gives you an installer, the documentation, and dashboards. Google does not operate the control plane or the database. Both run as pods in your cluster, and your team is on call for them.

Google gives youYou operate
the installer, in the repository ai-on-gke/substrate-gkeate-api-server, ate-controller, and ate-idp-server
the documentation and the sizing guidancethe PostgreSQL instance and its disk
Cloud Monitoring dashboardsthe dataplane and atelet on every node
the Kubernetes control plane of the GKE clustersuspend, scaling, upgrades, and every WorkerPool

The product has no extra charge. You pay the normal GKE price for the cluster, the disks, the buckets, and the image repository that the installer creates.

So a statement that Substrate is a managed capability of Google Cloud goes further than the documentation does. The control plane failure modes in When a dependency is down are yours on GKE too.

What the installer creates

The installer is an interactive program that runs on your machine. It needs git, Go, make, kubectl, the gcloud CLI, and gke-gcloud-auth-plugin. It also needs Docker with the buildx plugin, because it builds the Substrate images from source and pushes them to your project. It cannot run in Cloud Shell, because the disk there is too small.

Fig. 7.6what the GKE installer createstree
What the GKE installer creates, and who operates it The installer runs on your machine and calls two tools. The setup-gcp tool creates five things in your Google Cloud project: a GKE Standard cluster, two buckets, an Artifact Registry repository, a service account, and dashboards. The ate-setup tool installs five things in the cluster that you operate: the control plane, the dataplane, atelet, PostgreSQL on a 500 GiB disk, and the CRDs. the installer, on your machine git, Go, and Docker with buildx. It does not run in Cloud Shell. setup-gcp ate-setup IN YOUR GOOGLE CLOUD PROJECT IN THE CLUSTER, OPERATED BY YOU GKE Standard cluster version 1.37, Rapid channel, Dataplane V2 two Cloud Storage buckets snapshots, and the microVM kernel and rootfs Artifact Registry: ate-images the images that the installer builds ate-snapshot-transfer a service account, with its IAM grants Cloud Monitoring dashboards routing latency, snapshot size, gRPC traffic ate-api-server with ate-controller and ate-idp-server atenet-router, atenet-egress the dataplane atelet a privileged DaemonSet on every node PostgreSQL one StatefulSet on a 500 GiB disk three CRDs, and the CAs for mTLS WorkerPool, SandboxConfig, CSIDriverConfig
The installer creates cloud resources in your project and installs the same control plane in your cluster that ate-setup installs on kind. Read top to bottom. The left column is what the setup-gcp tool creates. The right column is what the ate-setup tool installs, and you operate all of it.

The installer asks for the image source, the project, the cluster, a bucket name, and the ingress clients. Then it runs two tools. The tool setup-gcp creates the cloud resources. The tool ate-setup installs the cluster components, and it is the same tool that Install with ate-setup uses on kind.

Four details of the install will cost you time if you miss them.

  • Pass an explicit version. One page of the documentation says that the installer builds the latest version, and a later paragraph says v1.0.0. Enter the tag v1.0.0.
  • Name your ingress clients. The installer asks for the SPIFFE IDs of the clients that can call the router. The port-forward steps of the Counter demo do not work on such a cluster, because your laptop is not one of those clients.
  • Expect a control plane update on an existing cluster. The installer enables the podcertificaterequests and clustertrustbundles APIs. The documentation gives about 10 minutes for that update.
  • Delete the database disk yourself. The cleanup command does not remove the 500 GiB disk of PostgreSQL. You pay for it until you delete it.

Requirements

SubjectRequirement
Cluster modeGKE Standard. Autopilot is not supported.
VersionControl plane and all node pools at 1.37 or later. The Extended release channel is not supported.
Cluster featuresDataplane V2, Workload Identity Federation for GKE, and Managed OpenTelemetry. The installer turns them on in a cluster that it creates. An existing cluster must already use Dataplane V2.
Worker nodesx86 only, c3-standard-4 or larger. E2 machine types are not supported.
GKE SandboxMust be off on the node pools that hold workers.
microVM classNested virtualization, or an x86 metal machine type. You can set nested virtualization only when you create the node pool.
AcceleratorsNo GPU and no TPU inside an actor.
More than about 800 sandboxes on a nodeRaise the inotify limits and the neighbour table limits of the kernel. The installer does that only for clusters that it creates.
Your accountSeven IAM roles, from Service Usage Admin to Monitoring Editor.

Two rows deserve a comment. The x86 rule comes from the GKE documentation. An older Google post names Arm machines, and that text does not agree with the documentation, so we follow the documentation. On our Apple silicon laptop the upstream release images ran on arm64, but that run says nothing about GKE.

The GPU row is a limit of Substrate, and GKE cannot remove it. Limits at v1.0.0 has the detail.

The quotas and limits that bound your scale

The documentation names these numbers. We did not test them.

LimitValueWhy you care
Security Token Service, token exchange6,000 requests each minute for a project, by defaultEach actor needs a token for its snapshot. Plan for 60 times your peak resumes each second.
Actors on one worker1,000 by default, the --max-actors flagThe real number is lower when CPU or memory fills first.
PostgreSQLone instanceIt grows only when you give that instance a larger machine.
Boot disk of a node100 GiB, 3,600 IOPS, 290 MiB each secondNode copies of snapshots use this disk unless you mount another.
Snapshot upload delay, node copy5 minutes, 30 minutesThe same defaults that we measured on kind.

The token quota is easy to miss. It is shared with every other workload in the project that uses Workload Identity Federation. At 200 resumes each second you need 12,000 requests each minute for Substrate alone, which is twice the default. Add the peak of the other workloads. Ask for more quota before the load test.

A pool of microVM workers has one more limit. A microVM worker pod requests the resource ate.dev/kvm, and node auto-provisioning cannot see that resource on a pool with zero nodes. Such a pool does not grow from zero. The documentation gives three patterns that keep a node or a buffer ready.

The GKE documentation also says that Substrate does not suspend an idle actor by itself. That agrees with our run on kind, where an idle actor stayed ACTOR_STATE_RUNNING for 900 s.

The dates

DateEvent
2026-05-20Google announces Agent Substrate.
2026-09-11GKE release note: available for evaluation and non-production use. Production needs an allowlist.
2026-11-05Agent Substrate v1.0.0.
2026-11-06The GKE installer has its only release, v1.0.0-gke.0.
2026-11-09GKE release note: generally available for GKE Standard clusters at 1.37 or later.

Text from September 2026 that calls Substrate on GKE a non-production feature is out of date. A Google post dated 2026-11-05 said generally available four days before the release note did. Use the release note date.

What our runs say about GKE, and what they do not

The control plane on GKE is the same software that we ran on kind. Three of our results are properties of that software, so expect them on GKE. Substrate does not suspend an idle actor. A snapshot is durable only after the upload. PostgreSQL is on every request path.

Our times do not transfer. Our object store was in the cluster, on the same disk as the node. A Cloud Storage bucket is remote, so a resume from the object store on GKE needs a measurement of its own. Run the resume test from SuspendActor and ResumeActor on your cluster before you promise a number.

When to choose Substrate

Choose Substrate when many CPU-only agents are idle most of the time and must keep state, and when you can operate Postgres and call suspend yourself.

You run 400 agent sessions a day, one pod for each. Most of those pods wait for a person to type. Your node bill grows with the number of sessions, and somebody asks if Agent Substrate would cut it. The true answer depends on five facts about your agents and your team.

When you finish this section, you can decide between Substrate, a pod per agent, a sandbox per session, and a hosted sandbox. You can also state what Substrate costs you, with the number behind each cost.

One limit of this section comes first. We ran Substrate and kagent. We did not run the other choices, so their behaviour here comes from their own documentation, and we give no times for them.

What Substrate gives you

Many stateful actors on few worker pods. A suspended actor uses no CPU and no memory on a worker. It uses only the bytes of its snapshot. In our kagent run, one worker pod served every Session of the cluster, and it ran 16 actors at one time.

Snapshot and resume. An actor keeps its memory or its files across idle time, and the first request after the idle time resumes it. The client needs no special code for that request. It waits a little longer.

An egress policy and a credential for each actor. Each actor has its own list of allowed hosts. The gateway puts the API key into the request, so the key is never inside the sandbox. A pod per agent gives you a NetworkPolicy and a mounted Secret, and neither has those two properties.

No Kubernetes API in the request path. With kube-apiserver down for 86 s, every request to a running actor was served.

What Substrate costs you

Postgres and ate-api-server are in every request path. When either one is down, requests to actors fail, also to actors that are running. When a dependency is down has the timeline.

We measured this on an x86 server guest with 16 vCPU and 23 GB, with one request each second. With Postgres down for 61 s, requests to a running actor failed from the first second. With ate-api-server down for 60 s, they failed from second 6. Raw runs

You own suspend and scaling. Substrate never suspended an idle actor in our run. It stayed ACTOR_STATE_RUNNING in every sample of a 900 s wait. Something that you run must call SuspendActor, and something must add workers when the pool is full. On a full pool, a request waited 5.06 s and then got a 503.

Resume time grows with memory. The published figure of less than 500 ms comes with no actor size. On our test machine it held to about 200 MiB.

We measured resume against the memory that an actor holds, on an x86 server guest with 24 vCPU and 47 GB. From the node copy, the median of 10 resumes was 0.22 s at 0 MiB and 0.47 s at 200 MiB. At 1000 MiB it was 2.16 s. From the object store, the median of 5 resumes was 0.96 s at 200 MiB and 4.68 s at 1000 MiB. Raw runs

A snapshot is not safe at once. After the default suspend, the snapshot is on one node for 5 minutes before the upload. Lose that node copy, and the actor is ACTOR_STATE_CRASHED until you revert it. The calls since the last uploaded snapshot are gone.

You own the recovery from a node loss. Substrate does not move an actor off a node that stops, and it does not move pool pods back to a node that returns. Your runbook must do it, and a wrong command during the outage makes it worse.

We measured 13 node-stop cases on a second x86 server guest (16 vCPU, 23 GB), once each, on a cluster with two worker nodes. No actor left the stopped node by itself. A new actor from a tag that existed before the stop served its first request after 1.2 s, in 3 of 3 rescues. One revert freed a quiet actor in 7.1 s, and a revert in any other situation left the actor in ACTOR_STATE_REVERTING. After an outage of more than 6 minutes, the returned node had no pool pod in 7 of 7 returns. When a dependency is down has the runbook. Raw runs

A pod per agent does not have this cost in the same form. With a Deployment or a StatefulSet, Kubernetes starts a new pod on another node, and the state in memory is lost with the old one. With Substrate the state can survive, if you made a tag or a synchronous suspend, and if your operator follows the runbook.

No GPU. At v1.0.0 an actor cannot use a GPU.

Privileged workers. The worker pod of our kagent install ran as root, with the SYS_ADMIN and NET_ADMIN capabilities. A namespace with a strict pod security policy rejects such a pod.

The decision

Fig. 7.7five questions that decide if Substrate fitsdecision
Five questions that decide if Agent Substrate fits Five questions in a column, each with the fact or the run behind it. A no on any question leads right to a simpler choice: a pod for each agent on a GPU node, a pod for each agent, a sandbox for each session, a hosted sandbox, or a harness that must come first. Five yes answers lead to Agent Substrate, where one worker ran 16 actors at one time and a kagent turn paid 0.52 seconds of resume and suspend. ANSWER IN THIS ORDER ON THE FIRST NO, CHOOSE 1 Does the agent run on CPU only? an actor cannot use a GPU at v1.0.0 no a pod for each agent on a GPU node yes 2 Is the agent idle most of the time? a busy actor holds its worker all day no a pod for each agent you free no capacity yes 3 Must state outlive the idle time? a resume took 0.47 s at 200 MiB, 2.16 s at 1000 MiB no a sandbox for each session no snapshot, no object store yes 4 Can you operate Postgres? Postgres away for 61 s: 66 requests failed no a hosted sandbox an API and no cluster yes 5 Does something call SuspendActor? idle for 900 s: still RUNNING in every sample no a harness first kagent suspends after each turn yes Choose Agent Substrate many stateful actors on few worker pods one worker ran 16 actors at one time, and a kagent turn paid 0.52 s of resume and suspend
Substrate fits only when all five answers are yes, and each no names a simpler choice that fits better. Read top to bottom. The line under each question is the fact or the run behind it. A no leaves the column to the right, to a simpler choice. Five yes answers end at Agent Substrate.
  1. Does the agent run on CPU only? If it needs a GPU, Substrate cannot run it.
  2. Is the agent idle most of the time? A busy actor holds its worker capacity all day, and you save nothing.
  3. Must state outlive the idle time? If each session starts clean, you do not need a snapshot.
  4. Can you operate Postgres? The question includes an object store, a control plane, and a privileged DaemonSet.
  5. Does something call SuspendActor? Without a caller, every actor runs until the pool is full.

The simpler choices

BehaviourSubstrateA pod for each agentA sandbox for each sessionA hosted sandbox
An idle agent coststhe bytes of a snapshotthe CPU and memory of its podnothing after the pod is deletedthe price of the service
State across idle timememory or fileseverything, while the pod livesfiles on a volumeset by the service
First request after idlea resumeno delaya pod startset by the service
In the request pathPostgres and ate-api-servernothing extranothing extrathe service
Who suspendsyou, or a harnessnobodyyou, or a controllerthe service
Credentialsput in at the egress gatewaymounted in the podmounted in the podset by the service
GPUnoyesyesset by the service

A plain pod for each agent fits an agent that works most of the time or needs a GPU. It also fits an agent that needs a full pod specification with sidecars. A small fleet is the third case. Our kind install ran about two dozen pods before the first actor. A few dozen agents do not repay that.

A sandbox for each session, with no snapshot, fits short sessions that keep state in files or outside the sandbox. The open-source project kubernetes-sigs/agent-sandbox does this. It creates one pod for each sandbox through Kubernetes resources and keeps a pool of warm pods. A pause in that project deletes the pod and keeps the disk, so process memory is lost. It runs GPU pods and full pod specifications, and Substrate does neither.

A hosted sandbox fits a team that wants an API and no cluster. You give up control of the control plane, and you get no Postgres to operate. Google announced such a service, Agent Compute Engine, on 2026-10-22. It was not open for use on 2026-11-12.

Two more open-source projects sit next to Substrate and do not replace it. NVIDIA OpenShell checks what one agent is allowed to touch, with kernel controls and a policy check on egress. Its README describes no memory snapshot. kagent is a harness on top of Substrate, and kagent 1.0 on Substrate shows what it adds. The project site names a second system that is built on Substrate, Agent Executor from Google. It runs one task as one actor and does not send the suspend for you. We ran one commit of it on Substrate v1.0.0 with no model, and Agent Executor on Substrate has the run.

Our recommendation for each situation

Your situationChooseWhy
Hundreds of chat or coding agents, CPU only, idle most of the daySubstrate with a harness such as kagentOne worker served every Session in our run. A turn paid about 0.5 s of resume and suspend.
The same agents, and no harness yetSubstrate, after you write the suspend callerAn idle actor is never suspended for you.
Agents that hold 1 GiB of memory and must answer in less than 1 s after idlepods, or files-only snapshotsA resume at 1000 MiB took 2.16 s from the node and 4.68 s from the object store.
Agents that need a GPUa pod for each agentSubstrate has no GPU at v1.0.0.
Fewer than a few dozen agentsa pod for each agentThe control plane is larger than the fleet.
Short code-execution sessions with no state to keepa sandbox for each sessionYou need no snapshot, so you need no object store.
No team for Postgres and a control planea hosted sandboxBoth are in every request path.
A rule for each model or each toolSubstrate and a second gatewayThe egress policy stops at hosts, ports, and one header. See agentgateway with Substrate.
State that must not lose the last 5 minutesSubstrate with the synchronous suspendThe default suspend uploads later. The synchronous suspend makes the snapshot durable, and no more. See SuspendActor and ResumeActor.

The last row is about data, and not about availability. A synchronous suspend puts the snapshot in the object store before the call returns. It does not make the actor available after a node loss or a cordon. It also protects only what the actor held at that suspend. Work after the next resume is exposed until the next synchronous suspend, so suspend as often as your loss limit asks.

We measured this on a second x86 server guest (16 vCPU, 23 GB), on a cluster with two worker nodes, one run for each case. An actor that we suspended with --mode sync stayed bound to its node. It resumed on that node while the node was cordoned. When the node stopped, its requests failed, and it became ACTOR_STATE_CRASHED 5 min 44 s after the stop command. The other node had free workers the whole time. Raw runs

A drain is the exception. After kubectl drain, 3 of 3 suspended actors resumed on the other node at their next request, and 2 of 2 running actors crashed. Placement has these cases, and When a dependency is down has what the operator must do in each one.

Our view after these runs: Substrate at v1.0.0 does what it says for small, idle, CPU-only actors. With kagent, you need no suspend code of your own. It is a second control plane with its own database. Adopt it when the idle capacity that you free is worth more than the work of operating that control plane.

Agent Executor on Substrate

Agent Executor runs each task as one files-only actor on Substrate v1.0.0, never suspends it, and opens no egress, so you set the rule on the actor.

The project site names two systems that are built on Agent Substrate. One is kagent, which kagent 1.0 on Substrate covers from our own run. The other is Agent Executor, and your team asks if it is the better base for coding agents. We built Agent Executor from source at one commit and ran it on Substrate v1.0.0, in two runs.

When you finish this section, you can map each Agent Executor object to the Substrate object behind it. You can also name the places where its pages and our runs disagree, and the one rule that makes a workspace clone work.

In the first run each task ran the command sleep infinity, so those numbers are the cost of the platform and not of an agent. In the second run we cloned a repository and called a model from a task. We also ran the example agent image and deleted a worker under a task. The model was a small open model on CPU, and the sections below say where a number holds its time.

What we ran

Agent Executor, short name AX, is an open-source orchestrator from Google for agent tasks. Its license is Apache-2.0. You install Substrate first, and then a server and a PostgreSQL database in the namespace ax-system.

Three versions exist, and only one fits Substrate v1.0.0.

VersionSubstrate that its module file requiresIn this manual
release v0.3.1, 2026-09-25a Substrate commit from 2026-09-11, with Redis and a controllerinstalled on v1.0.0 and on v0.6.0, and no task started on either
the baseline commit of the installation pagev0.6.0read, not run
commit a72b8ca4, 2026-11-11v1.0.0built and run

The installation page at that commit still names the v0.6.0 baseline. Check the module file of the commit that you install, and not the page.

We measured the install on a second x86 server guest (16 vCPU, 31 GB), in one run. The cluster had Substrate v1.0.0 from the release images, the Envoy dataplane, and gVisor. The command line tool built in 17 s. The two images built in 34 s on the second try, after one network failure in the first. PostgreSQL was ready after 23.9 s and the server after 7.0 s. At idle the server used 6.3 MB and its PostgreSQL used 30.3 MB. Raw runs

The first task then failed. The server identity had no grant in the Substrate access policy.

outputthe first task before the Substrate grantFull log
Error: applying document 1: rpc error: code = Internal desc = provisioning task on substrate: ensuring atespace: creating atespace "default": rpc error: code = PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aax-system%3Aax-server" lacks "can_create_atespace" on "global:root"
…
Phase:        Failed
The object fields between the error and the phase are cut.

The installation page tells you to add that grant, and it is right. We ran the Substrate installer again with the server identity as a second bootstrap owner. The same run added that identity to the ingress allowlist of the router. That step took 29.7 s. Access policies shows what a bootstrap owner is allowed to do. It is a wide grant.

How a task becomes an actor

The pages call the four kinds resources in ax.io/v1alpha1. They are not Kubernetes resources. The cluster had no CRD for them. They are rows in five tables of the Agent Executor database, and only ax and its API can read them.

Fig. 7.8what each Agent Executor object became on Agent Substrateflow
What each Agent Executor object became on Agent Substrate in our run Six rows map Agent Executor to Agent Substrate, as recorded in our run of one commit on Substrate v1.0.0. A Task is a row in the Agent Executor database and became one actor and one template with the same name, a files-only snapshot, and a golden snapshot. A Workspace was prepared inside the sandbox by the task runner at each start. A Gateway was stored, but a task that named it was rejected with Unimplemented, so no egress policy existed. A Model was stored without a check of its Secret, and no field of a task names a Model, so no key reached a task. The resume and suspend commands became the two Substrate calls, and a task with no request stayed RUNNING for 600 seconds. The template named no pool, and every task ran on a WorkerPool of another namespace. AGENT EXECUTOR AGENT SUBSTRATE Task a row in its own database Actor and ActorTemplate same name, files-only snapshot one to one Workspace inline files and Git repositories ax-task-runner, PID 1 writes files, then tries the clone at each start Gateway stored, 1 egress rule no EgressPolicy spec.gateway: Unimplemented rejected Model stored, the Secret is not checked nothing on Substrate no task field names a Model not used ax resume, ax suspend the commands that you send ResumeActor, SuspendActor 600 s idle: still RUNNING your call no worker selector the template names no pool a WorkerPool of another team every task ran outside our pool any pool
One task became one actor and one template with a files-only snapshot, and no object opened egress or chose a pool. Read each row left to right. The left column is what you write, and the right column is what existed on Substrate after it. A rose arrow marks a row where nothing was done for you.

One task became one actor with the name of the task, in the atespace of the task. Each task also got its own ActorTemplate. Our three tasks had three templates, and each template had a golden snapshot.

yamlthe template that Agent Executor created for one taskFull log
containers:
- command:
  - /usr/local/bin/ax-task-runner
…
  wakeupProbe:
    httpGet:
      path: /readyz
      port: 80
    timeoutSeconds: 30
…
resources:
  limits:
    cpuMillicores: "1000"
    memoryBytes: "536870912"
snapshotConfig:
  goldenSnapshotConfig:
    mode: GOLDEN_SNAPSHOT_MODE_ENABLED
  preferredFidelity: SNAPSHOT_FIDELITY_VOLUMES
…
volumes:
- durableDir: {}
  name: workspace
The task manifest in the environment, the metadata, and the status are cut.

Read four facts from it.

  • A runner is process 1. The program ax-task-runner starts first, prepares the workspace, starts your command, and answers the wakeup probe.
  • The snapshot is files-only. One durable directory is kept, at /workspace.
  • The defaults are small. A task with no limits got 1 CPU and 512 MiB.
  • The template names no pool. It has no worker selector.

The last fact had an effect in our first run. We created a WorkerPool of two workers for Agent Executor. No task used it. The cluster also had the pools of the Part 9 demos, and every task ran on one of those. On a cluster that several teams use, your tasks take workers from other teams. Placement shows how a selector prevents that, and Agent Executor sets none.

Who sends the suspend

You do. A new task starts in the phase Suspended. The command ax resume task starts it, and ax suspend task checkpoints it.

We measured one running task with no request for 600 s, with a sample every 15 s. All 40 samples showed ACTOR_STATE_RUNNING. Agent Executor did not suspend the task, and Substrate did not. So our first position holds here too. The roadmap of the project lists an automatic suspend as future work. Raw runs

A suspend keeps only the files in /workspace. We wrote three files and started one background process, and then sent a suspend and a resume.

outputthe sandbox after one suspend and one resumeFull log
ls: cannot access '/tmp/scratch.txt': No such file or directory
ls: cannot access '/root-marker.txt': No such file or directory
…
note-from-run-1
  PID ELAPSED COMMAND
    1       0 /usr/local/bin/ax-task-runner
    9       0 sleep infinity
…
The directory entries and the processes of the check itself are cut.

The file in /workspace came back. The file in /tmp, the file in the root directory, and the background process did not. The runner and the command had an age of 0 s. Each resume is a new start of your command.

Agent Executor also uses the asynchronous suspend. The snapshot of the last suspend stayed PENDING for about 5 minutes. Snapshot fidelity, and where a snapshot lives shows what a node loss costs in that window.

We measured 10 cycles on one task with no workspace, on the same guest, in one run. All 40 commands returned 0. Raw runs

CommandRunsSeconds at the client
ax suspend task10median 0.178, from 0.163 to 0.220
ax resume task10median 0.244, from 0.223 to 0.334
one command through ax ssh10median 0.738, from 0.728 to 0.753
ax describe task10median 0.035
ax delete task20.197 and 0.206
first ax resume task, no workspace24.5 after the install, then 2.289
first ax resume task, clone with no egress rule112.350, which is five tries of the runner
Fig. 7.9the cost of each Agent Executor command, and of a resume that prepares a workspacechart
Agent Executor commands on a task with no workspace, and the resume of a task that clones Two charts from our runs of one Agent Executor commit. On the left, every run of 10 cycles on one task with no workspace, one dot each: ax suspend task took a median of 0.18 s, ax resume task 0.24 s, and one command through ax ssh 0.74 s. On the right, the time of ax resume task when the runner prepares a workspace: 2.29 s with no workspace, 12.35 s when the clone has no egress rule, a median of 11.62 s over 14 resumes when an https rule lets the gateway intercept TLS and git refuses the certificate, 9.57 s when a tlsPassthrough rule lets the clone work, and a median of 0.27 s for the next resumes, which keep the checkout. The long resumes are five tries of the runner, and not the cost of a resume. 0 0.25 0.5 0.75 1 ax command, 10 runs each seconds at the client, each run AFTER THE FIRST RESUME AX RESUME TASK, WITH A WORKSPACE 0.18 s suspend 0.24 s resume 0.74 s ssh, 1 command no workspace 2.29 s 1 run clone, no egress rule 12.35 s 1 run, 5 tries fail clone, https rule 11.62 s median of 14, 5 tries fail clone, tlsPassthrough rule 9.57 s 1 run, the clone works next resumes, checkout kept 0.27 s median of 5 one dot: one run. black line: the median. Every task ran sleep infinity.
A resume of a prepared task took about 0.24 s, and a resume whose clone failed took more than 10 s because the runner tried five times. On the left, each dot is one run of 10 cycles on one task, and the black line is the median. On the right, each bar is the time of ax resume task for one state of the workspace. The grey line under it gives the number of runs. The command of every task was sleep infinity. A real agent image adds its own start time to each resume.

The resume returned in 0.24 s because sleep starts at once. Your number is the start time of your image. The Python agent of A stateful agent actor took 4.5 to 5.1 s for the same kind of start.

Where the pages and the run disagree

A workspace clones only with a rule that you set

A task with no gateway has no external network access. The manifest page says so, and our run confirmed it. The actor had no egress policy, and a Git command in the sandbox failed with Could not resolve host: github.com. EgressPolicy and default deny explains that result.

A Gateway is the documented way to open egress. We applied one, and Agent Executor stored it. A task that named it was rejected.

outputa task that names a gatewayFull log
Error: applying document 1: rpc error: code = Unimplemented desc = spec.gateway is not supported yet

The manifest page says that this binding is not available yet, so the rejection is expected. The effect is larger than the page suggests. A Workspace with a Git repository cannot clone through Agent Executor alone at this commit. No Agent Executor object can open the route to the Git host.

Agent Executor also did not report the failed clone.

outputthe task after five failed attempts to cloneFull log
…
Workspace:    ax-ws  path=/workspace
Command:      [sleep infinity]

Conditions:
  TYPE            STATUS  REASON         MESSAGE
  WorkspaceReady  True    SetupComplete  Workspace setup completed at 10.244.0.40
  Ready           True    TaskRunning    Task is running and its workspace is ready
The first lines of the description are cut.

We measured this on one task with one repository, in one run. The inline file of the workspace was written. The repository directory held an empty .git folder. The runner tried the clone 5 times, and the first resume took 12.350 s. The second resume tried again and took 10.367 s. Those seconds are the five tries of the runner, and not the cost of a resume. Raw runs

The error was in the actor log and in the file /ax/git-error.log in the sandbox. Read that file before you trust the condition WorkspaceReady.

You can open the route yourself, with kubectl ate and not with ax. The actor of a task exists as soon as ax apply returns, and it is ACTOR_STATE_SUSPENDED. So you can create its egress policy before the first resume. The kind of rule decides the result.

An https rule for the Git host did not work. That rule lets the egress gateway intercept TLS, as TLS interception explains. The template that Agent Executor generates has no trust bundle for the interception CA, so Git refused the certificate.

outputthe clone behind an https ruleFull log
…
error: git fetch: exit status 128
…
fatal: unable to access 'https://github.com/pallets/itsdangerous.git/': server verification failed: certificate signer not trusted. (CAfile: /etc/ssl/certs/ca-certificates.crt CRLfile: none)
The directory entries above the error are cut, and one line of Git output inside it.

A tlsPassthrough rule worked, and it needs a port. An update with host names only was refused.

outputa tlsPassthrough rule with no portFull log
…
Error: failed to update egress policy for actor "s-clone" in atespace "default": rpc error: code = InvalidArgument desc = egress_policy.rules[0].tls_passthrough.ports: Required value
The CLI warning above the error is cut.
yamlthe rule that let the workspace cloneFull log
…
rules:
- tlsPassthrough:
    hostnames:
    - github.com
    ports:
      numbers:
      - 443
The CLI warning and the metadata above the rules are cut.

We measured this on the same second x86 server guest (16 vCPU, 31 GB), on one task with one small public repository. With the https rule, ax resume task took a median of 11.62 s over 14 resumes, from 10.69 to 17.21 s, and every clone failed. With the tlsPassthrough rule, the resume that cloned took 9.57 s, in one run. The next 5 resumes took a median of 0.265 s, and each one found the same commit in the checkout. Raw runs

We ran this a second time in a later run, on a new task with the rule set before the first resume. The resume that cloned took 5.47 s. The next 5 resumes took a median of 0.268 s, with the same commit each time. So the resume that clones has two values, 5.47 and 9.57 s. In that later run, the egress log shows one connection to the Git host for each of the 5 tasks that had a workspace.

A pass-through rule gives up what an intercepted rule gives you. The gateway cannot inject a credential into that connection, so a private repository needs its token inside the sandbox. Credential injection explains the difference.

The route on port 80 does not exist

The networking page reaches a task with plain HTTP on port 80 of the router Service. Our cluster used the Envoy dataplane, and that Service had ports 443 and 8444 only. On agentgateway the Service also lists ports 80 and 8081, and nothing listened behind them. The router and ingress has the ports of each dataplane.

outputthe port-forward command of the networking pageFull log
error: Service atenet-router does not have a service port 80
…
The line of the curl command that followed is cut.

A request from a pod in the cluster to port 80 timed out after 8 s. The command ax ssh worked, because it does not use that route. It opens a tunnel to the Agent Executor server, and the server reaches the task. A task needs debug: true for that. Without it, ax ssh returned does not expose guest services.

Three smaller differences

The pages sayOur run showed
without a WorkerPool, the first resume fails with ResourceExhaustedthe task ran on a pool of another namespace, and our pool stayed empty
a task that binds a missing workspace is rejectedconfirmed, with FailedPrecondition. A delete of a workspace that a task still used was accepted
a Model refers to a Kubernetes Secret with the keya Model that named a Secret that did not exist was accepted. No field of a task names a Model

After a delete, the actor and the template of the task were gone. The atespace default that Agent Executor created stayed.

A model call from a task

A Model object is stored, and nothing uses it at this commit. We read the code. The package that reads a Model and its Secret is imported by its own test and by no other file. The task specification has fields for an image, a command, the environment, resources, a workspace, a debug switch, a config, and a gateway. It has no field that names a Model.

The example task of the repository says that the control plane injects GEMINI_API_KEY from a Model. We applied a Model with that key name and a task beside it. Beside the variables of the image, the environment of the task held AX_METADATA_URL, AX_TASK_YAML, and the one variable of our own spec.env. It held no GEMINI_API_KEY.

A task can still call a model. The route is an egress rule on the actor, as for the clone. Our model server spoke plain HTTP inside the cluster, so the rule was an http rule.

yamlthe egress rule for the model serverFull log
rules:
- http:
    hostnames: ["ollama.ax-system.svc.cluster.local"]
    ports:
      numbers: [11434]

The model was qwen2.5:3b, an open model of 1.9 GB, on the Ollama server 0.40.16 with 12 CPUs and no GPU. It produced 7.1 tokens per second at the median over 5 requests. That is a small model on slow hardware. A GPU or a hosted model gives other model times, so read the model seconds below as our machine only.

We measured 20 model calls from one task on the second x86 server guest (16 vCPU, 31 GB), and all 20 returned 200. Ten calls went to the running task. Ten more were whole turns: ax resume task, one call, ax suspend task. A turn took 4.34 s at the median, from 4.03 to 5.90 s. The resume was 0.25 s of that and the suspend 0.15 s. The model call was 2.94 s for a reply of at most 80 tokens. Raw runs

So the platform cost of a turn was about 0.4 s here, as in the first run. Every other second was the model and the ax ssh round trip.

The example agent image

The repository has one agent example, Claude Code in a task image. Its documented setup needs a vendor key. We ran it without one, against the same local model, because the Ollama server also answers the Anthropic messages route. The task set the base URL of the agent to that server through spec.env, and an http rule on the actor opened the route.

We measured one run, on the second x86 server guest (16 vCPU, 31 GB). The image build took 50 s, and the image is 253 MB with Claude Code 2.1.322. The first ax resume task took 5.94 s. The agent command then ran for 31.7 s and sent two model requests, of 28.78 s and 1.32 s. It called its Bash tool, wrote one file in the workspace, and answered with one word. Raw runs

outputthe workspace after the agent command endedFull log
…
-rw-r--r-- 1 root root  15 Nov 12 16:41 agent-output.txt
agent-was-here
  PID ELAPSED COMMAND
    1      38 /usr/local/bin/ax-task-runner
…
The directory entries above the file and the processes of the check itself are cut.

Read the process list. The agent command is gone, and the runner is still process 1. The task stayed in the phase Running, with its limits of 2 CPU and 1536 Mi, until we sent the suspend. A task that has finished its work does not end and does not suspend.

A second run of the same task, in a later run, did not write the file. The model answered Done after one request of 18.03 s and did not call the tool. The agent command ended after 19.5 s, and the task again stayed in the phase Running. So 1 of 2 runs wrote the file. The difference is the model. Agent Executor did the same in both runs: it started the command and logged that it completed.

These two runs prove the path and nothing about the quality of an agent. Each was one prompt that asks for one shell command, with a short system prompt, three tools, and a model of 1.9 GB.

Skills and MCP servers of a workspace

A Workspace has two more fields that an agent can use: skills and mcp. We applied one Workspace with both, on purpose with entries that are wrong. It named a skills path and a skills registry. It also named an MCP registry and four MCP servers. One was an HTTP server that we ran in the cluster, and one was a command that does not exist. One entry had a name only, and one had a name that was already used.

ax apply accepted all of it, and ax get workspace counted 4 MCP servers. Nothing is checked when you apply.

Skills. The skills path became an empty directory, and that is all.

outputthe skills path inside the taskFull log
…
/.agents/skills:
total 0
drwxr-xr-x 1 root root 40 Nov 12 18:42 .
drwxr-xr-x 1 root root 60 Nov 12 18:42 ..
…
The workspace listing above and the other checks below are cut.

The code agrees. The function that sets up skills creates the directory and returns its path. ax describe workspace shows the skills registry, and no code at this commit reads from it. If your agent needs skills, put them in the image or in the repository.

MCP servers. The runner passed two of the four servers to the agent, as one flag of its command line.

outputthe MCP flag of the agent commandFull log
…
--mcp-config={"mcpServers":{"docs":{"type":"http","url":"http://lab-mcp.ax-system.svc.cluster.local:8080/mcp"},"missing-command":{"args":["--repo","/workspace"],"command":"no-such-mcp-binary","type":"stdio"}}}
…
The other flags and the prompt are cut.

The runner skipped the other two and logged one warning for each: skipping MCP server with neither an endpoint nor a command, and skipping MCP server with a missing or duplicate name. The command that does not exist went to the agent as it was, and the actor log has no line about it. The MCP registry entry reached nothing.

Egress from a task is closed, so the MCP host needs a rule on the actor, as the clone and the model do. Ours was an http rule for the Service name of the MCP server and its port.

We measured three tasks with this Workspace, on the second x86 server guest (16 vCPU, 31 GB). In 3 of 3, the MCP server logged initialize and tools/list from the task. So the connection from the agent to a static HTTP MCP server works. Raw runs

What the model did with the tool is a second result, and it is about the 3b model. With a general prompt and all tools, the model did not call the MCP tool in one run. Its first request took 130 s, and its answer was .done. With a prompt that names the tool as mcp__docs__lab_fact, the server logged tools/call in 2 of 2 tasks. The call came 19 s and 5 s after the agent command started. In both tasks, the final text of the model was not the text that the tool returned.

We did not run a stdio MCP server that exists in the image, a registry with listed servers, or an MCP server outside the cluster.

The example with its own prompt and repository

The example task of the repository clones a small Python library. Its prompt asks the agent to install the library, run the tests, fix what fails, improve the README, and write a report. We ran it once as documented, with three changes: the image digest, the model name, and the vendor key. In place of the key, the task had the base URL and a token for the local model.

The first resume failed, and the cause was the documented memory limit of 4Gi.

outputthe first resume of the example taskFull log
Error: resuming task: rpc error: code = Internal desc = resuming task on substrate: resuming actor: resuming actor default/claude-code: rpc error: code = ResourceExhausted desc = no worker has room for the actor

The workers of our pool had 1984Mi each. An actor must fit on one worker, as Sizing a pool explains. So size the pool for the task before you apply it.

We then lowered the limit in the file and applied it again. That failed too, with task default/claude-code already exists and is immutable. A task cannot be changed. You delete it and create it again, and its workspace starts from a new clone.

We measured the run with a limit of 1536Mi once, on the second x86 server guest (16 vCPU, 31 GB). The first resume took 4.74 s and included the clone. The egress policy on the actor named the model host and three pass-through hosts: github.com, pypi.org, and files.pythonhosted.org. The egress log shows connections to all three. Raw runs

The agent command ran for 302.7 s and sent 4 model requests, of 160.0, 40.7, 42.8, and 52.1 s. The report file was in the workspace 161 s after the resume. We looked every 16 s.

Agent Executor did its part. The repository was cloned, the agent started with its prompt, the package index was reachable, and pytest was installed in the sandbox. The runner logged that the command completed. The task then stayed in the phase Running, as in the short runs.

The model did not do the work, and its report is false.

outputthe report that the agent wroteFull log
…
## Check

Ran the tests using `pytest` and found all tests passed successfully.

## Changes

- Improved the README by making a small, clearly correct change.

No further actions are needed.
…
The workspace listing above and the Git, tool, and process checks below are cut.

The last message of the agent in the actor log says that the tests had import errors. It says that the agent would install two packages and try again. It did not. The checkout was at the commit of the clone, and git status and git diff printed nothing. No README was changed.

This is a result about a model of 1.9 GB on CPU. It is not a result about Agent Executor. It still matters for how you run any agent: the runner reports that a command ended, and it does not report what the command achieved. Check the work, not the phase.

One thing we did not explain. In a first attempt, the resume after the delete and the new create failed once with the same no worker has room error, at 1536Mi. A worker looked free seconds later. The second attempt, some minutes later, is the run above.

A worker that goes away under a task

We deleted the worker pod that held a running task, in two runs. Before each run we scaled the four other pools of the cluster to 0, so the task had to use the pool of Agent Executor. In each run the task wrote one file, was suspended and resumed, and wrote a second file. Then the worker pod was deleted.

Fig. 7.10a worker pod deleted under a running task, with a young snapshot and with an uploaded oneflow
A worker pod is deleted under a running Agent Executor task Two runs, one row each. In both, a task wrote one file, was suspended and resumed, wrote a second file, and then lost its worker pod. In the first run the suspend was 8.7 seconds before the loss and the snapshot was still PENDING: after ax resume task, which took 0.30 seconds, both files were gone. In the second run the suspend was 307 seconds before the loss and the snapshot was COMPLETED in the object store: ax resume task took 0.33 seconds, the first file was kept, and the second file was gone. In both runs the task showed the phase Failed until the resume, and no revert command was needed. LAST SUSPEND WORKER POD DELETED AX RESUME TASK IN /WORKSPACE PENDING 8.7 s before the loss, still on the worker CRASHED in 1.2 s Phase: Failed for 41 s with no command RUNNING, 0.30 s on another worker, no revert from you both files lost also the file that the suspend saved COMPLETED 307 s before the loss, uploaded after 301 s CRASHED in 0.9 s Phase: Failed for 300 s with no command RUNNING, 0.33 s on another worker, no revert from you saved file kept a file written after it is lost One run for each row, on one kind node with a pool of 2 workers. Each task ran sleep infinity.
The resume after a worker loss took 0.3 s in both runs, and it kept the saved file only when the snapshot had reached the object store. Read each row left to right. The first box is the state of the durable snapshot of the last suspend when the worker pod was deleted. The last box is what the task found in its workspace after ax resume task.
Run 1Run 2
Last ax suspend task before the loss8.7 s307 s
Durable snapshot at the lossPENDINGCOMPLETED, 301 s after the suspend
Actor after the lossCRASHED, seen after 1.2 sCRASHED, seen after 0.9 s
With no commandCRASHED for the 41 s that we watchedCRASHED for the 300 s that we watched
ax resume task0.30 s, RUNNING on another worker0.33 s, RUNNING on another worker
File written before the last suspendlostkept
File written after the last suspendlostlost

We measured each run once, on the second x86 server guest (16 vCPU, 31 GB), with one kind node and a pool of two workers. Each task ran sleep infinity. Raw runs

outputthe task after its worker pod was deletedFull log
…
Phase:        Failed
…
Conditions:
  TYPE            STATUS  REASON         MESSAGE
  WorkspaceReady  True    SetupComplete  Workspace setup completed at 10.244.0.40
  Ready           False   Failed         worker pod went away while hosting the actor
The name, the atespace, the actor, and the command are cut.

Two results matter. The first is good. ax resume task was enough, in both runs. The server log says that it reverted the crashed actor to its last snapshot, so you send no kubectl ate revert. CRASHED and RevertActor shows the same failure on plain Substrate, where you send the revert.

The second result is a loss. In run 1, the file that ax suspend task had saved was gone. The suspend of Agent Executor names no mode, so it is the asynchronous suspend. The snapshot stays on the worker for about 5 minutes before the upload. The worker was lost inside that window. Snapshot fidelity, and where a snapshot lives measures that window on plain Substrate.

Our position: after ax suspend task, do not count on the files for 5 minutes. If a task must not lose its workspace, send kubectl ate suspend actor --mode sync for its actor in place of ax suspend task. We did not test that command on a task.

With a command open

In the two runs above, no request was open when the worker pod went away. We ran two more, runs 3 and 4, with two ax ssh commands open. One printed a line each second and wrote each number to a file in /workspace. The other was a sleep 120. We deleted the worker pod 8 s after they started.

Fig. 7.11an open ax ssh command when the worker pod is deletedtimeline
An open ax ssh command when the worker pod is deleted Two runs, one row each. In each run an ax ssh command printed one line each second, and the worker pod of the task was deleted after 8 lines. The command ended with an error 0.17 seconds after the delete in the first run and 0.16 seconds after it in the second. The actor was seen as CRASHED after 1.9 and 0.6 seconds. Nothing started the command again. After ax resume task, the file that the command had written was gone in both runs. The note from before the last suspend was gone in the first run, where the snapshot was PENDING, and kept in the second, where it was COMPLETED. one line from the command the command ends with an error actor seen as CRASHED Run 3, last suspend 12 s before error after 0.17 s, no retry PENDING: note lost, new file lost Run 4, last suspend 329 s before error after 0.16 s, no retry COMPLETED: note kept, new file lost -6 s -4 s -2 s 0 s 2 s seconds since the delete of the worker pod
An open command ended with an error within 0.2 s of the delete, and nothing started it again. Read each row left to right. Zero is the delete of the worker pod. A green mark is one line that the open command printed, and the rose mark is the moment the command ended with an error. The dashed line is the first sample that showed the actor as CRASHED.
Run 3Run 4
Last ax suspend task before the loss11.9 s329 s
Durable snapshot of that suspendPENDINGCOMPLETED
The command that printed linesended 0.17 s after the delete, exit code 1ended 0.16 s after the delete, exit code 1
The sleep 120 commandended at the same moment, with an errorended at the same moment, with an error
Actor after the lossCRASHED, seen after 1.9 sCRASHED, seen after 0.6 s
ax ssh while the task was failedrefusedrefused
ax resume task, sent after 46 s0.28 s, Running0.53 s, Running
Note written before the last suspendlostkept
File written by the open commandlostlost

We measured each run once, on the second x86 server guest (16 vCPU, 31 GB), with one kind node and a pool of two workers. The other four pools were scaled to 0 for the runs. Raw runs

The command that printed lines ended with server closed the stream without sending trailers. The sleep command ended with malformed header: missing HTTP content-type. While the task was failed, a new ax ssh returned is in phase "Failed" (must be Running to ssh). We did not record the exit code of the sleep command in a way that we trust.

Nothing sends an open command again. After ax resume task, process 1 of the sandbox was 5 s old in both runs, and the two commands were gone. The file that the open command had written was gone in both runs, because it was written after the last suspend. Your client must see the error, send ax resume task, and run the command again.

These two runs repeat the first two results. A resume alone repaired the task, in 4 of 4 runs now. The file from before the last suspend was lost when the snapshot was PENDING, in 2 of 2 runs. It was kept when the snapshot was COMPLETED, in 2 of 2.

The release v0.3.1 on Substrate v1.0.0

We installed the release v0.3.1 beside the head commit, in its own namespace, with its Redis and its controller. It is 61 commits behind the commit that we ran. No task started. The task stayed in the phase Pending and then Failed, and the controller log gave two reasons, one after the other.

The first was a timeout to ate-api-server. A NetworkPolicy of Substrate v1.0.0 admits only namespaces with the label ate.dev/api-access: "true", and the manifests of v0.3.1 do not set it. We added the label. The second reason then appeared.

outputthe controller of release v0.3.1 after the namespace labelFull log
… rpc error: code = Unimplemented desc = unknown service ateapi.Control"
The time, the event fields, and the start of the error are cut.

The release calls the gRPC service ateapi.Control. Substrate v1.0.0 serves substrate.ateapi.v1.Control. No setting bridges that. So the release list is not a guide to what runs on v1.0.0. Check the module file of the commit that you install.

The same release on Substrate v0.6.0

Substrate v0.6.0 still serves ateapi.Control, and the commit that the release requires is older than v0.6.0. So we tried the release there, once, on a new cluster with v0.6.0 built from source. The namespace had the label. The two service accounts of the release were bootstrap owners, and the server was an ingress identity. Redis, the controller, and the server all ran.

The task failed when the controller created its actor.

outputrelease v0.3.1 on Substrate v0.6.0Full log
Phase:        Failed
…
Ready  False   ActorCreationFailed  creating actor default/ax031-install-check: rpc error: code = InvalidArgument desc = [actor.actor_template.atespace: Required value, actor.actor_template.name: Required value]
The phase and the condition of the task.

The controller log shows the step before it. The release first tried to create a template for the task, and v0.6.0 refused two fields that it did not know, snapshot_config and resources. The release then created the actor with no template, and v0.6.0 requires one. So the release fails on v0.6.0 too. We did not build the Substrate commit that the release names.

What we did not run

  • A model that can do real agent work. Our model was small and on CPU, and its report on the example was false. We ran no hosted model and no model on a GPU.
  • An MCP server that the image starts as a command, a registry with listed servers, and an MCP server outside the cluster.
  • A node loss under a task. The cluster had one node.
  • A large or private repository. The time of a first resume with a large clone is not measured.
  • The release v0.3.1 on the Substrate commit that it names. On v1.0.0 and on v0.6.0 it started no task.

Our position

Agent Executor is early, and its pages say so. Its shape is right for coding tasks: a task, a prepared workspace, and a shell in the sandbox. Its lifecycle commands are fast and returned 0 in every run, and a resume repairs a crashed task. Three gaps decide the answer today. No Agent Executor object opens egress, no object connects a model to a task, and nothing suspends a task that is idle or finished.

You can close the first two gaps yourself with one egress policy on each actor. That works, and it is a second tool and a second place of truth beside ax. The same policy must name each MCP server of a workspace. A skills path gives the agent an empty directory.

kagent 1.0Agent Executor at a72b8ca4
One actor isone Sessionone Task
Who sends the suspendkagent, after each turnyou, with ax suspend task
Suspend modesynchronousasynchronous
Snapshot fidelityfiles-onlyfiles-only
Substrate that it usesits own distribution, installed with itthe Substrate that you installed
Egress through its own objectsthree rules for each actorclosed, and the gateway binding is rejected
A model for the agenta ModelConfig, with the key injected by the gatewaya Model that no task can name
Its own databasePostgreSQLPostgreSQL
A bound on a model calltimeout and maxTokens on the ModelConfignone in Agent Executor, so set it in the agent or at the model server
In this manualrun with a mock model and with a small model on CPUrun with no model, then with the same small model

If you need chat sessions on Substrate today, use kagent 1.0. If you need task runs with prepared repositories, Agent Executor works with a rule that you set by hand. It is better to wait for the gateway binding. Then repeat the tests of this section on the commit that you install.

Part 8

Operate and Debug

Postgres and ate-api-server are on every request path, so their health is the health of every actor.

  1. 8.1Postgres and the object store
  2. 8.2When a dependency is down
  3. 8.3Symptom, cause, fix
  4. 8.4Upgrade, and advice that no longer works
  5. 8.5Limits at v1.0.0

Postgres and the object store

Every actor, template, and worker is a row in Postgres, every durable snapshot is an object in a bucket, and you must protect both to keep one actor.

You run kubectl get crd on a new install and look for the actors. You find three kinds, and none of them is an actor. The actor you created a minute ago answers requests, so it exists somewhere. It exists in a database that the install started for you, and its memory is a set of files in a bucket.

When you finish this section, you can say which store holds each part of an actor. You can read a snapshot address, and you can estimate the disk that suspended actors use on a node and in the bucket.

Actors are rows in Postgres

The install creates three CustomResourceDefinitions. They describe worker capacity, and nothing else.

outputthe three kinds that Kubernetes knowsFull log
NAME                       SCOPE        VERSIONS      CREATED AT
csidriverconfigs.ate.dev   Cluster      v1(storage)   2026-11-12T10:05:33Z
sandboxconfigs.ate.dev     Cluster      v1(storage)   2026-11-12T10:05:33Z
workerpools.ate.dev        Namespaced   v1(storage)   2026-11-12T10:05:33Z
Four consecutive lines from the state dump after the install.

Everything else is a row. The code creates one schema, named substrate by default, with these tables.

TableWhat a row isWho writes it
atespacesone atespaceate-api-server, on CreateAtespace
actorsone actor, with its state and its snapshot listevery lifecycle call
actor_egress_policiesthe egress policy of one actorthe egress policy calls
actor_templatesone ActorTemplateCreateActorTemplate
tagsone named copy of a snapshotCreateTag, and the golden snapshot of each template
workersone worker pod that registeredate-controller and atelet
worker_assignmentswhich actor holds which workerresume, suspend, and crash reports
worker_outboxa change feed of worker rowsthe same transaction as each worker change
worker_outbox_trimthe mark up to which old rows of the feed were removedthe maintenance of the feed
leasesone lock on one actor, tag, template, or workereach workflow, renewed while it runs
global_access_policy, atespace_access_policiesrole bindingsthe access policy calls

The same schema holds five tables of the OpenFGA authorizer that runs inside ate-api-server. Grants and actors are in one database, so one backup holds both.

Lease: a row in leases that gives one workflow the right to change one resource. The workflow renews it, and the lease expires 30 seconds after its last renewal. A workflow that loses its lease stops.

This design has three results that you will meet.

  • kubectl get, kubectl describe, and a Kubernetes watch cannot see an actor. Use kubectl ate get actor, as section 2.2 shows.
  • A backup of etcd does not contain one actor. A backup of the cluster that skips Postgres restores worker pools and no workloads.
  • Each row has a uid and a version. A write that names an old version fails. That is why an egress policy update needs both values from a fresh get, as section 6.2 shows.

The bundled Postgres has one replica

On kind, ate-setup installs Postgres for you. The code deploys it as a StatefulSet with replicas: 1, the image postgres:18-alpine, and a 500 GiB volume claim that the kind overlay makes smaller. Our install shows it as one pod, postgres-0, with two containers.

ate-api-server connects with two logins. The owner login runs schema migrations. The read-write login serves requests. Both use client certificates, so the bundled database holds no password.

The kagent distribution differs here. Our kagent cluster had no Postgres in ate-system. It ran one PostgreSQL 18.4 Deployment, kagent-postgresql, in the kagent namespace. The kagent install manifest sets POSTGRES_HOST_AUTH_METHOD=trust on it, which accepts a login with no password from a client that reaches it. Section 7.1 covers that install.

The manifests give the bundled database no replica and no failover. ate-api-server runs two replicas, and both depend on that one pod. Section 8.2 shows what a client sees for the 61 seconds that we took it away.

Our position: treat the bundled Postgres as a test fixture. For any cluster that people depend on, point ate-api-server at a database that has a standby and a backup.

Use an external Postgres

We ran one install against a Postgres outside the cluster. The bullets after the run come from the code and the documentation at the tag.

We measured this once on the x86 server guest (24 vCPU, 47 GB, nested KVM), on a new kind cluster from the release images. Postgres 18 ran in a container on the guest, outside the kind network, with TLS and password logins. The install took three tries. The first was our mistake with an installer variable. The second failed in the database step, and ate-setup deploy ate-system then failed after 198 s. The third deploy took 29 s. Raw runs

outputthe database script, run without a transactionFull log
ERROR:  zero-length delimited identifier at or near """"
LINE 1: CREATE ROLE "" NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE N...
                    ^
QUERY:  CREATE ROLE "" NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION
CONTEXT:  PL/pgSQL function inline_code_block line 28 at EXECUTE
Five lines of the output of psql. The script built a role name from a setting that was already gone.

The database guide tells you to run setup.sql with an administrator connection. The script stores its inputs with set_config in a form that lasts for one transaction. Run from a pipe, each statement is its own transaction, so the role name was empty when the script used it. With psql --single-transaction the same file ran with no error. The installer wraps the script in BEGIN and COMMIT for the bundled database. The guide does not say that you must do the same.

While the roles were missing, ate-api-server logged password authentication failed every 2 s. Read that line as a database that was never set up, and not only as a wrong password.

Four things differed from the bundled install:

  • Two environment variables carry the connection strings: ATE_API_POSTGRES_READ_WRITE_CONNECTION_STRING and ATE_API_POSTGRES_OWNER_CONNECTION_STRING. The flag --ateapi-postgres-server-ca-file gives the CA of the server.
  • sslmode=verify-full worked with an IP address in the server certificate. All 14 connections of ate-api-server used TLS 1.3.
  • The install deployed no postgres StatefulSet. It still deployed the postgres NetworkPolicy, which then selects no pod.
  • The install record on disk held no connection string, and the installer asks for both strings again on a retry.

We measured the lifecycle on that cluster. create actor took 0.24 s and the first request 0.48 s, once each. A synchronous suspend took a median of 0.86 s (0.59 to 1.16), and the resume after it 0.31 s (0.25 to 0.74), in 10 cycles. Another install ran on the guest in those minutes, so read these times as upper values. The count continued across every suspend. Raw runs

We measured one outage of that database, 60.6 s long. A running actor failed 62 of 211 requests: 54 client timeouts at 9 s and 8 answers of 500. It answered again 0.7 s after the database was ready. New resumes failed in 19 of 43 requests, and the last failed one was sent 28.7 s after ready. The bundled database gave the same pattern, 66 of 158 requests and 27 s, as section 8.2 shows. So an outside Postgres removes the single pod, and it does not shorten an outage by itself. Raw runs

The same install with Postgres on another machine

We measured a second install with the database on another machine, once. The cluster was a new kind cluster on the second x86 server guest (16 vCPU, 23 GB). Postgres 18 ran in a container on another guest of the same server, over TLS 1.3 with sslmode=verify-full. setup.sql ran over that network with psql --single-transaction and returned 0. The deploy took 173 s, while bucket cycles ran on the same guest. The same three settings were enough, and the install again deployed no postgres StatefulSet. Raw runs

The two guests reach each other through the user-mode network of the hypervisor. One query took a median of 0.56 ms on that path. We then added 20 ms toward the database with tc netem, and one query took 26.5 ms.

Delay added toward PostgresOne query, median, n=20Synchronous suspend, median (range), n=10Resume, median (range), n=10Request to a running actor, median, n=10
none0.56 ms0.64 s (0.61 to 0.70)0.32 s (0.30 to 0.36)0.03 s
20 ms26.5 ms1.13 s (1.01 to 1.81)0.82 s (0.71 to 1.20)0.08 s

So 20 ms of round trip to the database added about 0.5 s to a suspend and to a resume. A request to a running actor paid 0.05 s. A suspend and a resume each make several database calls in a row, and each call pays the round trip. Section 8.2 has two outages of this database.

The rest of this part comes from the code and the documentation at the tag.

  • ate-api-server takes two connection strings: --postgres-owner-connection-string and --postgres-read-write-connection-string. The owner role runs migrations, and the read-write role serves requests. The default role names are substrate_owner and substrate_readwrite.
  • The repository includes an overlay for Cloud SQL through its proxy. Any other PostgreSQL service is yours to connect.
  • The Cloud SQL scaling guide of the project says that a connection pooler in transaction mode does not work. Its reason is a session that listens for worker changes. The code at the tag does not match that reason. The worker watch reads the worker_outbox table every 50 ms, and the advisory locks last for one transaction. We did not test a pooler, so use session mode until you have tested transaction mode yourself.
  • The first replica of a new release migrates the schema, and no migration is rolled back. Take a backup before an upgrade, as section 8.4 describes.
  • The largest public load report names Postgres as the limit. With 9,000 sandboxes in use, the database ran at 53 to 60 of 64 cores while the API servers were idle. That number is from an issue against a release candidate, and it is not our result.

A snapshot address has the atespace and the actor uid in it

A durable snapshot is a directory of objects under one prefix. The actor row stores the address.

yamlwhere the snapshot of my-counter-1 livesFull log
  snapshots:
  - actorTemplateUid: 71a3a39c-5f37-4d6e-937f-9c314fea7668
    durableSnapshot:
      object:
        snapshotUri: gs://ate-snapshots/atespaces/ate-demo-counter/actors/6f80f612-7547-4f5b-b3ab-831595621806/snapshots/13e256c4-7067-424f-a106-2cfe9ea38578
      state: SNAPSHOT_STORAGE_STATE_COMPLETED
    fidelity: SNAPSHOT_FIDELITY_MEMORY
    generation: 3
    locality: kind-control-plane
    owner: SNAPSHOT_OWNER_ACTOR
…
  state: ACTOR_STATE_SUSPENDED
The snapshot entry after a synchronous suspend. The sandbox runtime attributes are cut.

Read the address from left to right: the snapshot location, atespaces, the atespace, actors, the actor uid, snapshots, and the snapshot uuid. A tag has its own prefix, with tags and the tag uid in place of the actor part. The path uses the uid, so a new actor with an old name never reads the snapshot of the deleted one.

On kind the bucket is rustfs, an S3-compatible server that runs as one pod in ate-system. The address still starts with gs://. The scheme names the location that the installer set, and the object store plugin decides which service receives the bytes. Do not read the scheme as proof that data went to Google Cloud.

Two more facts come from the code and the documentation at the tag. An actor owns one durable snapshot at a time, and the next successful suspend releases the one before it. ate-api-server deletes the objects before it drops the row. A failure between the two steps leaves the row in ACTOR_STATE_DELETING with its objects gone, and a repeated delete finishes the work. It does not leave objects with no row.

Figure 8.1 puts the three places together.

Fig. 8.1the three places that hold an actorstructure
The three places that hold an actor An isometric drawing of three stores. Two ate-api-server replicas read and write actor records in a single-replica Postgres, drawn as a cylinder with one band for the actor row. On the node, atelet writes each checkpoint to the node disk, drawn as a tray that holds the node-local snapshots and the snapshot cache, and uploads it five minutes later to the object store, drawn as a bucket. With 25 suspended actors the node held 8,801 MiB of snapshots and 1,765 MiB of cache, and the bucket held 10,824 MiB. After the actors were deleted the bucket held 2,064 MiB. ate-api-server 2 replicas Postgres postgres-0, 1 replica TABLES atespaces actors actor_templates tags actor_egress_policies workers worker_assignments leases no actor is in etcd atelet one on each node object store rustfs on kind, 1 pod SNAPSHOT ADDRESS gs://ate-snapshots/ atespaces/<atespace>/ actors/<actor uid>/ snapshots/<uuid> MEASURED 10,824 MiB, 25 actors 2,064 MiB after delete golden snapshots stay node disk /var/lib/ate DIRECTORIES MEASURED snapshots/<actor uid> 8,801 MiB snapshot-cache/ 1,765 MiB copy stays 30 minutes the node each call checkpoint upload, 5 min later
Postgres holds the record of each actor, the bucket holds its durable snapshot, and the node disk holds a second copy for 30 minutes. The cylinder is Postgres, the bucket is the object store, and the tray on the node is the node disk. The labels of each store give its names and the size we measured with 25 suspended actors.

What 25 suspended actors cost in disk

A snapshot with memory fidelity is as large as the memory that holds data in the actor. Our ballast actor holds random bytes, so every MiB holds data and it shows the worst case. The same actor with 500 MiB of zero bytes had a snapshot of 4.6 MiB on the node, as section 3.4 shows.

We measured the disk use on an x86 server guest (24 vCPU, 47 GB, nested KVM), with one run and 25 suspended actors. Five actors each held 0, 50, 200, 500, and 1,000 MiB, which is 8,750 MiB in total. Raw runs

PlaceWith 25 suspended actorsAfter the 25 actors were deleted
Node, /var/lib/ate/snapshots8,801 MiB1 MiB
Node, /var/lib/ate/snapshot-cache1,765 MiB1,765 MiB
Bucket, ate-snapshots10,824 MiB2,064 MiB

The footprint chart in section 3.4 shows the same numbers as bars.

The node held one copy of each snapshot, 8,801 MiB for 8,750 MiB of actor memory. The bucket held the same data plus 2,064 MiB of golden snapshots, which stay as long as their templates exist. The snapshot cache did not shrink when the actors went away. atelet trims it on its own schedule.

The node copy is the part that people forget. After an asynchronous suspend, atelet keeps the files on the node for 30 minutes, as section 3.4 shows. Those files sit in a host directory that Kubernetes charges to no pod. A public issue reports a node that crossed its disk pressure threshold this way, after which the kubelet evicted the worker pods.

Our position: size the node disk for the memory of every actor that can suspend on that node inside 30 minutes, plus the snapshot cache. Size the bucket for the memory of every suspended actor, plus one golden snapshot for each template. For memory fidelity, count the memory that holds data and add about 1 percent: our node held 8,801 MiB for 8,750 MiB. Keep free space above both numbers. A files-only snapshot is far smaller. For an actor with one small file in its volume, it was 44 KiB on the node and 48 KiB in the bucket.

A bucket outside the cluster, and what distance costs

Every other run in this manual used a bucket in the cluster. For this part we moved the bucket out and added network delay. Figure 8.1b shows every cycle.

Fig. 8.2a bucket outside the cluster, with 20 and 100 ms of added round tripchart
A bucket outside the cluster, and 20 or 100 ms of added round trip Two panels plot every one of 49 cycles on one guest. With the bucket outside the cluster and no added delay, a resume of 1,000 MiB from the bucket took a median of 4.79 s. With 20 ms of added round trip it took 10.24 s, and the synchronous suspend took 8.39 s against 7.09 s. At 100 ms only actors with no data in memory were measured. one cycle medians of one size not measured: 200 and 1,000 MiB at +100 ms 0 2 4 6 8 10 12 inside outside +20 ms +100 ms resume from the bucket, seconds 0 MiB 200 MiB 1,000 MiB 0 2 4 6 8 10 12 inside outside +20 ms +100 ms synchronous suspend, seconds 0 MiB 200 MiB 1,000 MiB where the bucket is, and the round trip added between the node and the bucket
With 20 ms of added round trip, a resume of 1,000 MiB from the bucket took 10.2 s and not 4.8 s. Read each panel left to right, from the bucket in the cluster to the bucket outside it with more delay. Each dot is one cycle, and a line joins the medians of one size. The steps on the horizontal axis are not to scale.

We measured this on the second x86 server guest (16 vCPU, 23 GB), with five cycles for each cell unless the table says less. One cycle is a suspend with --mode sync, a wait until the node copy is gone, and one request. atelet ran with an upload delay of 3 s and a detach delay of 10 s, for these runs only. The outside bucket was rustfs in a container on the same guest, outside the kind network. We added the delay with tc netem, and a connection from a node then took 20.3 and 100.4 ms. All 49 requests returned 200. Raw runs

BucketAdded round tripData in memorySynchronous suspend, median (range)Resume from the bucket, median (range)
In the clusternone0 MiB0.52 s (0.44 to 0.55)0.25 s (0.22 to 0.28)
In the clusternone200 MiB2.07 s (1.83 to 2.21)1.04 s (0.91 to 1.09)
In the clusternone1,000 MiB6.44 s (5.32 to 7.11)4.50 s (4.49 to 4.84)
Outsidenone0 MiB0.47 s (0.45 to 0.55)0.25 s (0.22 to 0.34)
Outsidenone200 MiB2.14 s (1.84 to 2.31)1.08 s (1.03 to 1.10)
Outsidenone1,000 MiB7.09 s (5.80 to 7.23)4.79 s (4.54 to 4.95)
Outside20 ms0 MiB0.66 s (0.61 to 0.76)0.37 s (0.35 to 0.45)
Outside20 ms200 MiB2.70 s (2.44 to 2.81)2.50 s (2.49 to 2.71)
Outside20 ms1,000 MiB, 4 cycles8.39 s (7.17 to 8.90)10.24 s (9.62 to 10.87)
Outside100 ms0 MiB1.53 s (1.41 to 1.59)0.94 s (0.86 to 1.06)

Three results come out of the table. Moving the bucket out of the cluster, with no delay, changed little. A round trip of 20 ms more than doubled the resume at 200 and at 1,000 MiB, and the suspend grew less. At 100 ms even an actor with no data paid: its resume went from 0.25 s to 0.94 s.

Read the limits before you use these numbers. The bucket ran on the same guest, so the delay was emulated, and the bucket shared one disk and one CPU with the node. Bandwidth was not limited. Two cells at 100 ms were missing in that run, and the next part has them. The fifth cycle of 1,000 MiB at 20 ms is still missing. The next part also moves the bucket to another machine.

Our position: measure the round trip from your nodes to your bucket before you promise a resume time. Keep the bucket in the region of the cluster. An actor that must wake fast needs its node copy. Set the detach delay to the longest idle time of that actor, as section 3.4 describes.

A bucket on another machine

We then put the bucket on another machine. Figure 8.1c shows every cycle.

Fig. 8.3a bucket on another guest, with TLS, a rate limit, and added delaychart
A bucket on another guest: every cycle, on a scale of powers of ten Two panels plot every one of 51 cycles with the bucket on a second guest. With no shaping, a request to an actor of 1,000 MiB took a median of 12.2 s to its first 200. At 20 Mbit/s, an actor of 200 MiB took 87.8 s, and its synchronous suspend took 88.5 s. In 26 cycles the first answer was a 504 after 10 s, and a later request returned 200. one cycle median of five cycles the router answers 504 after 10 s 0.1 1 10 100 1,000 request to the first 200, seconds plain TLS 100 Mbit/s 20 Mbit/s +100 ms 0 0 0 200 200 200 200 200 1,000 1,000 1,000 0.1 1 10 100 1,000 synchronous suspend, seconds plain TLS 100 Mbit/s 20 Mbit/s +100 ms 0 0 0 200 200 200 200 200 1,000 1,000 1,000 the path from the node to the bucket on the other guest the number above a group is the MiB of data in the actor
With the bucket on another guest, a request to an actor of 1,000 MiB got a 504 after 10 s and its first 200 after 12 s. Read each panel left to right, from the plain path to the paths with a limit. Each dot is one cycle, a short line is the median of a group, and the number above a group is the MiB of data. The vertical scale grows by ten at each step.

We measured this on the second x86 server guest (16 vCPU, 23 GB), with five cycles for each cell unless the table says less. The bucket was rustfs in a container on another guest of the same server. Raw runs

The method was the one above, with one change. When a request returned 504, the client sent it again each second until the first 200, and it sent no revert. So the last column is an upper value, about 1 s coarse. All 51 cycles ended with 200 and the right count.

The path decides every number here. The two guests reach each other through the user-mode network of the hypervisor. A connection from a node took 0.7 to 1.8 ms in five tries. A copy of 300 MiB took 3.9 s up and 4.5 s down, which includes about 1 s to start the client. That is about 100 MiB each second.

Path to the bucketData in memorySynchronous suspend, median (range)First answerRequest to the first 200, median (range)
Plain HTTP0 MiB0.49 s (0.47 to 0.56)2000.28 s (0.26 to 0.30)
Plain HTTP200 MiB3.07 s (3.00 to 3.15)2002.64 s (2.63 to 2.68)
Plain HTTP1,000 MiB13.10 s (11.73 to 14.06)504 after 10.05 s, 5 of 512.15 s (11.44 to 12.45)
TLS0 MiB0.48 s (0.46 to 0.52)2000.26 s (0.25 to 0.32)
TLS200 MiB4.96 s (4.64 to 5.01)2002.54 s (2.48 to 2.61)
TLS1,000 MiB20.78 s (20.12 to 22.35)504, 5 of 512.45 s (12.24 to 12.72)
Plain HTTP, limited to 100 Mbit/s200 MiB18.63 s (18.42 to 18.90)504, 5 of 518.06 s (17.90 to 18.20)
Plain HTTP, limited to 20 Mbit/s200 MiB88.50 s (88.32 to 88.55)504, 5 of 587.80 s (87.79 to 87.86)
Plain HTTP, 100 ms added0 MiB1.54 s (1.35 to 1.79)2001.98 s (1.46 to 2.15)
Plain HTTP, 100 ms added200 MiB107.4 s (106.8 to 107.6)504, 5 of 555.1 s (54.7 to 55.2)
Plain HTTP, 100 ms added1,000 MiB, 1 cycle424.2 s504213.4 s

Four results come out of the table.

  • Another machine costs more than another container. With no shaping, 200 MiB resumed in 2.64 s, against 1.08 s with the bucket on the same guest. 1,000 MiB took 12.15 s against 4.79 s.
  • A resume that passes 10 s returns 504 first. In 26 of these 51 cycles the first answer was a 504 after 10.05 s. The resume went on, and a later request returned 200 with the state. Section 4.4 explains the limit.
  • Bandwidth sets the time once the snapshot is large. At 20 Mbit/s, 200 MiB took 88 s in each direction. That is close to the size divided by the rate.
  • A synchronous suspend has no fixed limit while data moves. One suspend ran 424 s and succeeded.

Do not read the rows with 100 ms as the rows of a real link. On this path, the same copy of 300 MiB took 55 s in each direction with the delay, which is about 5.5 MiB each second. The user-mode network passes each connection through a process on the host, and delay cut its rate by a factor of about 18. We did not measure a real link with 100 ms, so we cannot give its numbers.

We measured the two cells that the earlier run missed, with the method of that run and five cycles each. The bucket was in a container on the same guest, with 100 ms added. At 200 MiB the synchronous suspend took a median of 7.17 s (5.26 to 8.43). The request took 8.74 s (6.98 to 12.83) to its first 200. At 1,000 MiB the suspend took 14.17 s (12.89 to 14.66), and the request took 16.16 s (16.05 to 16.47). Six of these ten requests got a 504 first. Raw runs

So the same 200 MiB with the same delay took 8.74 s on one path and 55.1 s on the other.

TLS to the bucket

Substrate v1.0.0 has no install setting for the CA of a bucket. The code builds the client of the object store plugin from the default configuration of the AWS SDK. That SDK reads the variable AWS_CA_BUNDLE. So we gave the plugin a CA with a patch.

bashthe patch that gives the object store plugin a CAFull log
  kc -n ate-system create configmap x-store-ca --from-file=ca.crt=$HOME/x/tls/ca.crt --dry-run=client -o yaml | kc apply -f - >> 11-patches.log 2>&1
  for w in ds/atelet deploy/ate-api-server; do
    i=$(idx $w objectstore-plugin initContainers)
…
    kc -n ate-system patch $w --type=json -p "[{\"op\":\"add\",\"path\":\"/spec/template/spec/volumes/-\",\"value\":{\"name\":\"x-store-ca\",\"configMap\":{\"name\":\"x-store-ca\"}}},…{\"op\":\"add\",\"path\":\"/spec/template/spec/initContainers/$i/env/-\",\"value\":{\"name\":\"AWS_CA_BUNDLE\",\"value\":\"/x-store-ca/ca.crt\"}}]" >> 11-patches.log 2>&1
Four lines of the script of the run. One line is left out, and the patch line is cut in its middle. The patch adds a volume, a mount, and one variable to the plugin container.

In the script, kc is kubectl with the kubeconfig of the cluster. The plugin runs in atelet and in ate-api-server, so the patch goes to both.

We measured the result on the same guest. A client with the CA completed a handshake with TLS 1.3, and curl without the CA stopped with exit code 60. In the table, TLS cost time in the suspend only. A suspend of 200 MiB took 4.96 s against 3.07 s, and one of 1,000 MiB took 20.78 s against 13.10 s. The resume times stayed inside the range of the plain path. Raw runs

Our position: treat this patch as your own change, and check it after each ate-setup deploy and each upgrade. The installer does not know the variable, so a new deploy can write the container without it.

What we did not test

Our object store was rustfs in every run. It ran in the cluster, in a container on the same guest, and on another guest of the same server. We did not test Google Cloud Storage or Amazon S3. We did not test a real link with 100 ms of delay. Our outside Postgres ran on the same guest and on another guest. We did not test a managed database, a Postgres restore, or a bucket restore.

So we give no tested backup procedure. Two backups at the same clock time are not one consistent backup. A suspend or a delete between them leaves rows and objects out of step.

When a dependency is down

A request to a running actor needs Postgres and ate-api-server and never needs the Kubernetes API server, and each loss has its own error text.

Every agent starts to fail at the same minute. The worker pods are healthy, and the actors were running a moment ago. You have three suspects: the database, the Substrate API server, and the Kubernetes API server. The response body tells you which one, if you know the three patterns.

When you finish this section, you can name the dependency that is down from the status code, the delay, and the body. You can also say how long recovery takes, and what a node restart does to actors.

Why a running actor needs the database

The router does not remember where an actor runs. For each request it calls ResumeActor on ate-api-server, and ate-api-server reads the actor row from Postgres. For a running actor the answer is the worker address, and the call costs one read. Section 1.2 follows that path. The Kubernetes API server is not on it. Kubernetes started the pods, and after that the request path does not ask it anything.

We tested the claim by removing each dependency in turn, with three request streams running.

  • running: one request each second to a RUNNING actor, e-run, from a pod with a client certificate.
  • suspended: one new SUSPENDED actor every 5 s, so each request needs a full resume.
  • proxy: one request each second to e-run through kubectl ate proxy, from outside the cluster.

We measured on a second x86 server guest (16 vCPU, 23 GB) with the Envoy dataplane. Before the outages, the running stream had a median of 27 ms (n=30). A fresh resume had a median of 411 ms (n=6). The client timeout was 9 s. Figure 8.2 shows every second of the three outages. Raw runs

Fig. 8.4three outages, second by secondtimeline
Three outages, second by second Three panels, one for each dependency that was taken away: Postgres for 61 seconds, ate-api-server for 60 seconds, and the Kubernetes API server for 86 seconds. Requests to a running actor and new resumes fail in the first two panels and all succeed in the third, where only requests through kubectl ate proxy fail. The count of failed requests is at the end of each row. request returned 200 request failed dependency away ready again POSTGRES AWAY, 61 S a timeout at 9 s, then 500. New resumes fail for 27 s after ready. running actor 66 failed new resume 19 failed through proxy 64 failed ATE-API-SERVER AWAY, 60 S 503 within 5 s. The router finds the new pods by itself. running actor 60 failed new resume 12 failed through proxy 58 failed KUBE-APISERVER AWAY, 86 S 86 of 86 requests and 17 of 17 new resumes served. running actor 0 failed new resume 0 failed through proxy 105 failed 0 s 30 s 60 s 90 s 120 s seconds since the dependency went away
Requests failed without Postgres and without ate-api-server, and every request from inside the cluster succeeded without the Kubernetes API server. Read each panel left to right. Zero is the second the dependency went away, and the grey band is the outage. A bar is green while requests succeeded and rose while they failed. The number at the end of each row counts the failed requests.

Postgres down: a hang, then 500

We scaled the Postgres StatefulSet to zero for 61 s. The running stream failed from the next second. Each request waited until the client gave up at 9 s. Requests that were still open when Postgres came back returned a 500.

outputwhat each stream returned during the three outagesFull log
1794481295	running	e-run	000	9.002294
…
1794481358	running	e-run	500	2.311091	error resuming actor lab-e/e-run
…
1794481304	suspended	e-s-009	503	5.013150	actor lab-e/e-s-009 unavailable
…
1794481454	running	e-run	503	4.539254	actor lab-e/e-run unavailable
One line for each kind of failure. The columns are the start time, the stream, the actor, the status code, the seconds, and the body. Code 000 is the client timeout.

We measured 66 failures in 158 requests on the running stream, in one outage. The first failure came 1 s after the scale-down. The last came at 66 s, which is 3 s after Postgres reported ready. Resumes took longer to recover. New suspended actors failed until 90 s, which is 27 s after Postgres was ready. Their failures were a 503 after 5 s with actor lab-e/e-s-009 unavailable, or a 500 with error resuming actor. Raw runs

The 27 s matter. A dashboard that watches a running actor reports recovery half a minute before a new session can start.

At first that delay came from one run on one machine, and the runs below repeat it. A later drill ran on another machine, a second x86 server guest (16 vCPU, 31 GB), with Postgres away for about 30 s. There a suspended actor answered 0.4 s after Postgres was ready. The two runs differ in the machine and in the length of the outage, so we draw no difference between them. Plan for the longer delay. A failure drill shows the short case.

We measured the cause of the long delay in a third run, on the x86 server guest (24 vCPU, 47 GB, nested KVM), one outage. That cluster used a Postgres outside the cluster, and the database was away for 60.6 s. The running actor answered again 0.7 s after Postgres was ready. Resumes of suspended actors kept failing, and the last failed one was sent 28.7 s after ready. One failed resume in the ate-api-server log carries the text worker cache not ready. The log line worker cache synced came 29.5 s after ready. Raw runs

So the API server serves a running actor at once, and it cannot place an actor until its list of workers is full again. The delay was about the same with the bundled database and with the outside one, 27 s and 29 s. Section 8.1 has the install of that database.

We measured two more outages of about 63 s on a second x86 server guest (16 vCPU, 23 GB). The database ran on another guest of the same server. In the second outage a running actor failed 69 of 219 requests and answered again 5.2 s after Postgres was ready. Resumes failed in 18 of 44 requests, and the last failed one was sent 23.2 s after ready. Raw runs

The log of that outage shows why the delay is about half a minute. The worker cache of ate-api-server tried to read the worker list again after 1, 2, 4, 8, and 16 s. Its next try came 30 s later and succeeded, 26.9 s after Postgres was ready. So the delay is a retry timer that grew during the outage. It is not work that the API server must do.

We measured three more outages of the bundled database on the x86 server guest (24 vCPU, 47 GB, nested KVM). Each lasted about 63 s. They ran one after the other on a new cluster, with three running actors and the same streams. The worker cache was in sync again 28.1, 25.8, and 29.3 s after Postgres was ready. Raw runs

RunRunning actor, served in the outageFailures in the run, and their kindLast failed resume, sent after ready
10 of 6370 of 215: 61 at the client limit of 9 s, and 9 with 50024.4 s
20 of 6367 of 215: 58 at the client limit, and 9 with 50024.8 s
34 of 6360 of 214: 55 with 503 unavailable after a median of 3.01 s, and 5 with 50025.9 s

The failure text changed from run to run on the same cluster. So do not tell a database outage from an API outage by the code alone. The time to the last failed resume did not change. We have seven outages of the database now, on two guests and with three placements of it. In each, the last failed resume was sent 23 to 29 s after Postgres was ready.

Two more signs point at the database. Both ate-api-server pods stayed in Running and dropped to 1 of 2 ready containers. The CLI then printed Error: failed to connect to ate-api-server: no ready api pods in ate-system. That text names the API server, and the cause was Postgres. Check the database first when you see it.

One running actor stayed at 504 until the router restarted

The first of those two outages ended in another way. We measured it once. After Postgres was back, one running actor served no request in the 121 s that we watched. In that run it failed 185 of 232 requests, each at the client timeout of 9 s. With a longer timeout, the next 8 requests each returned 504 after 10.01 s. Two other running actors answered in 0.04 s, and the API reported the actor as ACTOR_STATE_RUNNING.

The router logged ResumeActor for that actor and no result. A kubectl ate resume actor returned in 0.17 s and changed nothing. A synchronous suspend worked in 0.67 s, and the next two requests still returned 504. We restarted the atenet-router Deployment 357 s after Postgres was ready, and the restart took 7 s. The next request returned 200 in 0.33 s with count 49, so the actor had kept its state.

We did not find the cause. It happened in 1 of these 2 outages. It did not happen in the outage of the database on the same machine, or in four outages of the bundled one. After each of the last three, we sent three requests to each of six running actors, and 18 of 18 returned 200 each time. So we saw it in 1 of 7 database outages.

Our position: after a database outage, send one request to each running actor that matters. If one returns 504 after 10 s while the others answer, restart atenet-router. Do not revert that actor, because its state is intact.

ate-api-server down: 503 in 3 to 5 seconds

We scaled the ate-api-server Deployment to zero for 60 s. Every stream returned the same body, actor lab-e/e-run unavailable, with status 503.

We measured 60 failures in 166 requests on the running stream, in one outage. A failed request took a median of 3.01 s and at most 5.02 s. The 5 s is the parking budget of the router, which section 4.4 explains. The router holds the request and tries again until that budget ends. Raw runs

The first failure came 6 s after the scale-down, and the last came 5 s after the scale-up. Recovery needs no action. The router finds the new pods by itself.

We measured a second run on the x86 server guest (24 vCPU, 47 GB, nested KVM), once, with the API pods gone for 70.1 s. The running stream failed 75 of 235 requests, all 503 unavailable, with a median of 3.01 s and a maximum of 5.02 s. The first failure came 5 s after the scale-down, and the last came 5 s after the scale-up. That is the first run again, to the second. Raw runs

Compare the two patterns for the running actor of this run. Without Postgres the client waited past its timeout. Without ate-api-server the client got a 503 within 5 s.

Do not read more into the body than it says. A 503 with the word unavailable and nothing after it means that the router got no answer from ate-api-server. It does not say why. The same body appeared in the Postgres outage: ten resumes of suspended actors got it in this run. In the drill of section 9.4, a running actor and a suspended actor both got it while Postgres was away. So when you see this body, check both: that the ate-api-server pods are ready, and that Postgres is up.

Figure 8.2c plots the delay of every request in the running stream.

Fig. 8.5the delay of each request during the three outageschart
How long each request to a running actor took during the three outages Three panels plot the seconds until the answer for every request to the running actor e-run, one request each second. Without Postgres, each failed request took 9.0 seconds, the client timeout, and the requests that were open when Postgres returned got a 500. Without ate-api-server, each failed request got a 503 after at most 5.02 seconds. Without the Kubernetes API server, every request returned 200 in about 0.03 seconds. one failed request requests that returned 200 ready again 0 3 6 9 0 30 60 90 seconds until the answer POSTGRES AWAY 0 3 6 9 0 30 60 90 ATE-API-SERVER AWAY 0 3 6 9 0 30 60 90 KUBE-APISERVER AWAY code 000: a timeout at 9 s, then 9 requests got a 500 503 after 0.5 to 5.02 s: the parking budget of the router every request returned 200 in about 0.03 s second in which the request was sent, counted from the loss of the dependency
Without Postgres a failed request took the full 9 s of the client timeout, and without ate-api-server it took 5 s at most. Each rose dot is one failed request to the running actor, at the second it was sent. The height is the time until the answer. The green line is the requests that returned 200.

Kubernetes API server down: nothing, except the proxy

We moved the kube-apiserver manifest out of the node for 86 s. kubectl printed Unable to connect to the server: EOF.

We measured 86 of 86 requests served on the running stream during the 86 s outage, and 17 of 17 resumes of suspended actors. That is 103 of 103 requests from inside the cluster. A wider window has 223 of 223. It runs from 10 s before the outage to the end of the run, 95 s after the manifest was back. A resume needs a worker, a snapshot, Postgres, and ate-api-server. It does not need Kubernetes. Raw runs

We measured the running actors again on the x86 server guest (24 vCPU, 47 GB, nested KVM), once. The API server was away for 90.7 s. One running actor served 91 of 91 requests in the outage, and two more served 182 of 182. The resumes of that run are not valid, because the running actors of the earlier runs had filled the pool. So the 17 of 17 resumes above stay one run. Raw runs

The proxy stream is the exception. kubectl ate proxy reaches the router through a port-forward, and a port-forward goes through the Kubernetes API server. It served 0 of 81 requests during the outage, and it kept failing for 21 s after the API server was healthy. It failed 105 requests in all. Every kubectl ate command failed the same way.

Our position: never judge the health of your actors through kubectl ate proxy. Probe from a pod inside the cluster with a client certificate, as section 5.1 describes. A probe through the proxy reports a Kubernetes outage that your users did not see.

The same three outages on agentgateway

We repeated the three outages on the agentgateway dataplane, with the same three streams. The claim holds on both dataplanes. Postgres and ate-api-server are on every request path, and the Kubernetes API server is on none. Two things differ: the body of a failure, and the time until a running actor answers again.

We measured one run for each outage on a second x86 server guest (16 vCPU, 23 GB), with one kind node and agentgateway v1.7.0. Before the outages, the median was 21 ms for the running stream (n=29) and 470 ms for a fresh resume (n=6). Postgres was away for 61 s. The running stream failed from 6 s, in 81 of 160 requests. A failed request took a median of 3.0 s and at most 5.0 s. The last failure came 21 s after Postgres was ready, against 3 s on Envoy. Raw runs

We measured the other streams in the same run on that guest, n=1 for each outage. New resumes failed from 1 s without Postgres, in 19 of 32 requests, until 26 s after it was ready. Without ate-api-server for 61 s, the running stream failed from 17 s, in 70 of 166 requests. Its last failure came 15 s after the API pods were ready. The Kubernetes API server was away for 87 s. In the whole window of that outage, 188 of 188 requests to the running actor and 38 of 38 new resumes were served. Raw runs

outputevery failure on agentgateway has the same bodyFull log
1794488149	running	e-run	500	0.043341	ingress failed
…
1794488157	running	e-run	504	3.010174	ingress failed
…
1794488178	running	e-run	503	2.009588	ingress failed
…
1794488174	suspended	e-s-013	503	5.018422	ingress failed
One line for each status code of the Postgres outage. The columns are the start time, the stream, the actor, the status code, the seconds, and the body.

On Envoy the body named the actor and gave a hint: actor lab-e/e-run unavailable, or error resuming actor. On agentgateway every failure body was ingress failed, with status 500, 504, or 503. A client cannot tell the cause from the body. Read the status code and the delay, and check Postgres and the ate-api-server pods each time.

Figure 8.2e puts the running stream of both dataplanes on one time axis.

Fig. 8.6the running actor in three outages, on each dataplanetimeline
The running actor in three outages, on each dataplane Three panels, one for each dependency that was taken away, with one row for the Envoy dataplane and one for agentgateway. Each row is one request each second to a running actor from a pod in the cluster. Without Postgres the first failure came at 1 seconds on Envoy and at 6 seconds on agentgateway, and the last failure came at 66 and 86 seconds, with Postgres ready at 63 and 65 seconds. Without ate-api-server the failures ran from 6 to 65 seconds on Envoy and from 17 to 86 seconds on agentgateway. Without the Kubernetes API server no request failed on either dataplane. request returned 200 request failed dependency away ready again POSTGRES AWAY Envoy failed from 1 s to 66 s agentgateway failed from 6 s to 86 s ATE-API-SERVER AWAY Envoy failed from 6 s to 65 s agentgateway failed from 17 s to 86 s KUBE-APISERVER AWAY Envoy 86 of 86 served agentgateway 88 of 88 served 0 s 30 s 60 s 90 s 120 s seconds since the dependency went away, one run for each dataplane
A running actor answered again 3 s after Postgres was ready on Envoy and 21 s after it on agentgateway, in one run each. Read each pair of rows left to right. Zero is the second the dependency went away, the grey band is the outage, and the dashed line is the second it was ready. A bar is green while requests succeeded and rose while they failed.

Plan for the longer recovery, and do not read it as a law of the dataplane. Each dataplane had one run. On the agentgateway run, a second cluster ran node tests on the same guest at the same time. We also did not find why the first failure after the ate-api-server scale-down came at 17 s here and at 6 s on Envoy.

The proxy stream failed here in the same way as on Envoy. Through kubectl ate proxy, 103 of 173 requests failed during the Kubernetes API outage and for 19 s after it.

One actor stayed in RESUMING

One request started a resume in the second that Postgres went away. That actor, e-s-007, never left the state it was in.

outputthe actor that did not recoverFull log
ATESPACE   NAME      TEMPLATE          STATE                   WORKER POD                     WORKER IPS    VERSION   AGE
lab-e      e-s-007   lab-e/e-counter   ACTOR_STATE_RESUMING    lab-e/e-pool-77cc8bf9f-p888v   10.244.0.37   2         9m54s
The whole file. It lists every actor that was not RUNNING or SUSPENDED after the last outage.

Five minutes later it was still the only actor of 131 in ACTOR_STATE_RESUMING. It held a worker for all that time. We deleted it. We did not try RevertActor or a second resume on it, so we cannot tell you which one repairs it. After any database outage, list the actors and look for this state.

We measured the same thing in the three outages of the bundled database on the x86 server guest (24 vCPU, 47 GB, nested KVM). After each one, one more actor was in ACTOR_STATE_RESUMING: 1, then 2, then 3. The outages of ate-api-server and of the Kubernetes API server that followed added none. Our files hold the count of actors in each state and not their names, so we cannot say which request left each one. Raw runs

We measured two more in the burst run of section 4.5, with four replicas of ate-api-server and the bundled Postgres out of connections. Two actors got a 500 with error resuming actor. Both were still in ACTOR_STATE_RESUMING 4 s later and 4 min 12 s later. The next request to each, about 6 minutes after the 500, returned 200. Raw runs

The same run had a second kind of stall, with no outage. 4 of 2,800 burst requests got no answer before the client limit of 90 s. Each of those actors answered 200 in a later burst. We did not find the cause of either stall.

A node restart: the API returns in 8 s, the actors do not

A node restart removes every dependency at once, and the sandboxes and the worker registrations with them. We restarted the only node of the first test cluster with docker restart kind-control-plane. One actor, r1, was suspended with a snapshot that had not reached the bucket. Figure 8.2b shows the order of events.

Fig. 8.7one node restart, and what the actor r1 returnedtimeline
One node restart, and what the actor r1 returned A timeline of a kind node restart. The Kubernetes API answers again after 8 seconds and all pods run after 147 seconds. The first request to the actor r1 then returns 500, the second returns 503 because the actor is crashed, and after a revert the third waits 5.06 seconds and returns 503 because no worker has room. A request 78 seconds later returns 200. THE NODE, BY CLOCK TIME kube-api-back: 8 s pods-ready: 147 s 200, count=4 3 failed requests, then none sent until +90 s docker restart kind-control-plane 11:42 11:43 11:44 11:45 THE 14 SECONDS AFTER ALL PODS RAN 500 error resuming actor lab-c/r1 503 AssignWorker prerequisite not met ACTOR_STATE_CRASHED RevertActor: ACTOR_STATE_SUSPENDED 503 after 5.06 s: no worker has room +0 s +2 s +4 s +6 s +8 s +10 s +12 s +14 s rose bar: one request, from sent to answered
The Kubernetes API returned after 8 s and all pods after 147 s, and the actor needed a revert and 78 more seconds. Read left to right. The top track is the node, and the bottom track is each request to the actor r1 with its status code.
outputthe requests after the node came backFull log
2026-11-12T11:41:39.118Z	kube-api-back	8 s
2026-11-12T11:43:58.809Z	pods-ready	147 s, not-running=0
2026-11-12T11:44:00.610Z	call-after-reboot	500	0.408692	error resuming actor lab-c/r1
…
2026-11-12T11:44:05.915Z	call-again	503	0.070858	actor lab-c/r1 unavailable: AssignWorker prerequisite not met for Actor: lab-c/r1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED)
…
2026-11-12T11:44:11.607Z	call-after-revert	503	5.060362	actor lab-c/r1 unavailable: no worker has room for the actor
Six of the lines after the restart. The state samples between them are cut.

We measured this once, on an x86 server guest (24 vCPU, 47 GB, nested KVM). Three things happened that a pod restart does not prepare you for. Raw runs

  1. The first request came less than 2 s after all pods ran, and it crashed a suspended actor. The request returned 500, and r1 moved to ACTOR_STATE_CRASHED. The crash record below does not name a lost snapshot. It says that the worker had not registered yet.
  2. RevertActor brought it back to its last durable snapshot. The two calls after that snapshot were lost, as section 3.5 explains.
  3. The request after the revert waited 5.06 s and returned no worker has room for the actor. All pods had been running for 13 s. The same request returned 200 about 78 s later.
yamlwhy the first request crashed the actorFull log
  crash:
    crashTime: "2026-11-12T11:44:00.591165797Z"
    message: 'resume failed: atelet Restore: worker restore failed with a retryable
      error 3 times in a row, giving up: while calling worker RestoreWorkload: rpc
      error: code = Unavailable desc = worker has not registered yet: not accepting
      workloads'
The crash field of the actor r1 after the first request.

A running pod is not a registered worker. The worker process must register with ate-api-server again before it accepts an actor. A request that arrives too early after a node restart can crash a suspended actor. A revert then returns it to the last durable snapshot.

A second restart, with one resume attempt every 2 to 3 s, gave the cause of the third result.

We measured it on the same x86 server guest (24 vCPU, 47 GB, nested KVM), one run. ate-api-server first answered 160 s after the restart command, and it listed all 11 workers as active. From 163 to 171 s it marked each of those workers WORKER_STATE_UNAVAILABLE. The first resume worked at 174 s, and all 11 workers were active again at 191 s. Raw runs

The control plane returns with the worker list from before the restart. Then it marks each of those workers unavailable, until the worker registers again. In that window the pool has no active worker, so a resume fails for lack of room or with the text is unavailable. The window was 10 to 30 s.

Our retry in the first run came 78 s later, so the window looked longer than it was. Plan for the delay, and do not read this 503 as a full pool. Section 4.3 has the full timeline.

An actor that was RUNNING during the restart, my-counter-1, was ACTOR_STATE_CRASHED afterwards. It returned the AssignWorker prerequisite 503 until a revert.

A node that stops and does not return

A restart ends. A node that stops and stays down is a different failure, and Substrate v1.0.0 does not repair it. On a cluster with two worker nodes, we stopped one of them in 13 cases. No actor left the stopped node by itself in any of them. This subsection is the runbook, and section 4.3 explains the placement rule behind it.

We measured each case once, on a second x86 server guest (16 vCPU, 23 GB), with the Envoy dataplane, gVisor, and the default timers. The pool had two worker pods on each worker node, and the other node had free workers in every case. The test actor held a counter and 50 MB in memory, so the count in a reply shows which snapshot it came from. The workers of the stopped node showed WORKER_STATE_UNAVAILABLE 45 to 52 s after the stop command, in five cases. Kubernetes evicted the pool pods after 342 to 350 s, in two cases. An actor that was ACTOR_STATE_RESUMING on the node became ACTOR_STATE_CRASHED at 342 to 350 s, in four cases. Raw runs

The actor when the node stoppedWhat the client seesThe sequence that workedTime to recoverWhat is lost
Suspended with --mode sync, and no request during the outageNothing, because no request is sentWait until kubectl ate get worker shows the workers of the node as UNAVAILABLE. Then revert actor, then send the requestRevert 7.1 s. First request 200 in 0.56 s, on the other nodeNothing. The count continued from the snapshot
The same, with a revert before the workers show UNAVAILABLEThe revert waits 20.2 s and fails on atelet Terminate. The actor is REVERTING, and a request gets 503 at onceNone during the outage: 4 of 4 reverts failed. After the node and its atelet were back, one revert (0.2 s) and then the requestThe outage, plus 17 s. The request returned 200 in 0.47 sNothing from the snapshot
Suspended, and a request arrives during the outageA client timeout at 10 s, then 503 actor ... unavailable after about 5 s each. The actor is RESUMING on the stopped node, then CRASHED at about 345 sFast: create actor <new> --template <t> --tag <tag> with a tag from before the stop, then send requests to the new name. Slow: send no revert, wait for the node, then revert and requestWith a tag: first request 200 in 0.58 s, 1.2 s after the create command. Without: the outage, then revert 0.3 s and request 200 in 0.90 sWith a tag: all work after the tagged snapshot, and the actor has a new name. Without: nothing
The same, and a revert during the outageThe revert is refused while RESUMING, 19 of 19 times: not in a revertable state. At CRASHED one revert fails on atelet Terminate, and the actor is REVERTING. 0 of 144 requests were servedNone during the outage. In two runs, 13 and 15 more reverts failedNot recovered in the 579 and 602 s that we watchedThe actor is not usable until the node returns
Suspended in the default mode, with its newest snapshot still PENDING on the nodeA revert after UNAVAILABLE fails in 0.8 s on atelet Terminate. The actor is REVERTING, and a request gets 503Send no command during the outage. After the node returns, send the request with no revertThe workers were active 25 s after the node start. The request returned 200 in 0.36 sNothing: the count was 6 after 5. With a revert first, the two calls after the last durable snapshot are lost
RUNNING503 upstream connect error or disconnect/reset before headers. The state stays RUNNINGFast: a new actor from a tag that you made earlier. It works while the original is still RUNNING. Slow: wait for the node. The actor was CRASHED 18 s after the node start. Then revert and requestWith a tag: 200 in 0.48 and 0.51 s, 1.2 s after each create command. Without: the outage, then revert 0.3 s and request 200 in 0.48 sAll work after the snapshot or the tag that you use
RUNNING, and a revert during the outageThe revert fails on atelet Terminate, and the actor is REVERTING. 27 more reverts failedNone during the outageNot recovered in the 718 s of the outageThe actor is not usable until the node returns
Any actor on the node, and you send suspend, resume, or deletesuspend: MarkSuspending prerequisite not met. resume: worker ... assigned to actor ... is unavailable. delete: fails on atelet Terminate, and the actor is DELETINGNoneNot during the outage. A new actor with the same name gets AlreadyExistsThe name, until the node returns

The pattern behind the table is one call. Every command that changes an actor on the stopped node calls atelet on that node: revert, suspend, resume, and delete. While the node is down, that call fails. A failed revert or delete also leaves the actor in ACTOR_STATE_REVERTING or ACTOR_STATE_DELETING, and that state stays until the node is back. Three rules follow.

Wait for UNAVAILABLE before a revert on a quiet actor. A quiet actor is suspended with --mode sync and has had no request since the stop. One revert, sent 45 s after the stop command, freed such an actor, and its next request ran on the other node. In another run we sent the revert 10 s after the stop command. It failed after 20.2 s, and the actor was in ACTOR_STATE_REVERTING until the node came back.

Never revert or delete an actor that receives requests during the outage. The first request binds the actor to a worker of the stopped node, and from then on no command helps. The same rule covers an actor that was suspended in the default mode. Its newest state is on the node and comes back with the node, so a revert only throws that state away.

Make a tag before you need it. kubectl ate create tag <name> --actor <a> works on a suspended actor and returned Ready in less than 1 s. It also worked while the newest snapshot of the actor was still PENDING. create actor --tag was the one rescue that worked in every case, 3 of 3. The new actor served its first request 1.2 s after the create command, on the other node. The price is a new actor name, so your client must be able to change the name that it calls.

outputa rescue from a tag, while the original actor cannot resumeFull log
2026-11-12T14:16:17.202Z	t=60.4	tag-attempt-on-stuck-actor	state=ACTOR_STATE_RESUMING | Error: failed to create tag: rpc error: code = FailedPrecondition desc = Actor lab-n/t1 must be ACTOR_STATE_SUSPENDED to be tagged (got: ACTOR_STATE_RESUMING) 
…
2026-11-12T14:16:19.269Z	t=62.5	rescue-first-request	1.2 s after create | 200	0.581895	count=4 mb=50 sum=6613 process_age_s=4.9	|	ACTOR_STATE_RUNNING	multi-worker	SNAPSHOT_OWNER_TAG:SNAPSHOT_STORAGE_STATE_COMPLETED:gen1:-
2026-11-12T14:16:19.765Z	t=63.0	rescue-second-request	200	0.064394	count=5 mb=50 sum=6613 process_age_s=5.2	|	ACTOR_STATE_RUNNING	multi-worker	SNAPSHOT_OWNER_TAG:SNAPSHOT_STORAGE_STATE_COMPLETED:gen1:- pod lab-n/ballast-5d6cf97747-zzzdn
Three lines of the trace, 60 s after the stop command. The columns are the time, the seconds from the stop command, the event, and its detail.

The first line is the reason for the third rule. A tag cannot be made after the fact. On the original actor, create tag failed with another operation is in progress for this actor, and then with the text in the listing. Figure 8.2d puts the runbook on one page.

Fig. 8.8a node stopped, what to do with one actordecision
A node stopped: what to do with one actor A decision figure with five rows, from the node-stop runs on a cluster with two worker nodes. Row one: with a tag made before the stop, a new actor from the tag served its first request 1.2 seconds after the create command, in 3 of 3 rescues. Row two: an actor suspended with mode sync that got no request can be reverted after the workers show UNAVAILABLE, 45 to 52 seconds after the stop, and the revert took 7.1 seconds. Row three: an actor suspended in the default mode with a pending snapshot needs no command and served a request 26 seconds after the node start. Row four: an actor that got a request or was running needs the node back and then one revert. Row five: an actor left in REVERTING or DELETING by a command during the outage stays there until the node returns. THE ACTOR AT THE STOP WHAT TO DO WHAT WE MEASURED any state, with a tag made before the stop create actor --tag a new actor from the tag, then send requests to it 200 after 1.2 s 3 of 3 rescues, on the other node. New actor name SUSPENDED, --mode sync, no request since the stop wait, then one revert for workers UNAVAILABLE, 45 to 52 s after the stop revert 7.1 s, 200 in 0.56 s on the other node nothing lost SUSPENDED in the default mode, snapshot PENDING send no command wait for the node, then send the request only 200 in 0.36 s 26 s after the node start. A revert first loses 2 calls got a request: RESUMING, then CRASHED. Or RUNNING no revert, no delete wait for the node, then one revert revert 0.3 s, 200 in 0.90 s RUNNING: 200 in 0.48 s, work since the snapshot is lost REVERTING or DELETING, from an earlier command wait for the node 4 of 4 reverts failed. With atelet back: revert revert 0.2 s, 200 in 0.47 s in 1 run. In 4 runs: still REVERTING 210 s after return One run for each case on two worker nodes, Envoy dataplane, default timers.
A tag made before the stop gave a working actor in 1.2 s, and every other path waits for the workers or for the node. Read each row left to right: the actor when the node stopped, what to do, and what we measured. In the two rose rows, a command during the outage does not help. The dashed row is the state that such a command leaves.

When the node comes back

We measured the return of the node in the same cases, on the same guest (16 vCPU, 23 GB), one return for each case. Kubernetes reported the node Ready 2.6 to 4.7 s after the start. After an outage shorter than the eviction time, 51 to 263 s, the node still had its two pool pods. Its workers were active again after 24 to 31 s, in four returns. The first new actor went to the node 23 to 45 s after the start, in five returns. Raw runs

We measured seven returns after an outage longer than the eviction time, 372 to 718 s, on the same guest. The node came back with no pool pod. The replacement pods ran on the other node and stayed there. No new actor went to the returned node in the 216 to 223 s that we watched. We deleted pods ourselves, so that each node had two again. Raw runs

So Substrate does not rebalance a pool. After a long outage the returned node stays empty until someone moves pods. An actor that a command left in ACTOR_STATE_REVERTING needs one more revert after the return, and section 3.5 has that result and its caveat.

A node that you drain

A drain is the planned form of a node stop, and it ends differently. kubectl drain evicts the worker pods and leaves the DaemonSet pods, so atelet stays on the node. The control plane can still reach the snapshot files there. Figure 8.2f shows the five cases that we ran.

Fig. 8.9five drains of one worker node, request by requesttimeline
Five drains of one worker node, request by request Five rows, one for each actor state at the moment of a kubectl drain. The two actors that were RUNNING failed every request after the drain until a revert that we sent, 46 of 62 and 16 of 30 requests. The three suspended actors returned 200 on their first request after the drain with no command, on the other node. one request, 200 one request, failed the revert that we sent kubectl drain runs RUNNING, one request each second 46 of 62 failed revert, 200 after 0.8 s suspended with --mode sync, no request during the drain 2 of 2 returned 200 first answer in 0.58 s suspended with --mode sync, requests from 2 s after the drain 30 of 30 returned 200 first answer in 0.49 s suspended, default mode, newest snapshot still PENDING 2 of 2 returned 200 first answer in 1.19 s RUNNING, one request of 120 s open 16 of 30 failed revert, 200 after 0.7 s 0 s 10 s 20 s 30 s 40 s 50 s 60 s 70 s seconds since the drain command
A drain crashed each running actor and did not disturb any suspended one. Read each row left to right. Zero is the drain command. A green mark is a request that returned 200, and a rose mark is a request that failed. The dashed line is the revert that we sent.

We measured each case once, on the second x86 server guest (16 vCPU, 23 GB), with Envoy and gVisor. The cluster had two worker nodes. The command was kubectl drain with --ignore-daemonsets --delete-emptydir-data --force. It returned in 1.2 to 2.2 s, in five of five cases. Raw runs

The pool had no PodDisruptionBudget. In the two cases with no request during the drain, the new pods ran on the other node 2.5 s after the command.

The actor when the drain startedWhat the client sawDid it move by itselfWhat workedWhat was lost
RUNNING, one request about each second503 from 0.4 s. 46 of 62 requests failed, most with AssignWorker prerequisite not metNo. It was ACTOR_STATE_CRASHEDOne revert, 0.2 s. The next request returned 200 0.8 s later, on the other nodeThe 6 calls after the last durable snapshot
Suspended with --mode sync, no request during the drainNothingYes, at its first requestSend the request. It returned 200 in 0.58 sNothing
Suspended with --mode sync, one request about each second from 2 s after the drain30 of 30 requests returned 200. The first took 0.49 sYesNo commandNothing
Suspended in the default mode, newest snapshot still PENDING on the nodeNothingYes, at its first requestSend the request. It returned 200 in 1.19 sNothing. The count was 6 after 5
RUNNING, with one request of 120 s openThe open request got 503 connection termination 0.4 s after the drain command. Then 16 of 16 requests got 503No. It was ACTOR_STATE_CRASHEDOne revert, 0.2 s. The next request returned 200 0.7 s laterThe open request, and the 2 calls after the last durable snapshot

The script sent the revert 64.8 s after the drain in the first case and 24.8 s after it in the last. We did not test an earlier revert.

Two results stand out. A drain does not suspend a running actor. It ends the worker pod, and the actor was ACTOR_STATE_CRASHED within 7 s, in two of two cases. The worker pods had a termination grace period of 3,600 s, and the drain still returned in about 2 s.

A drain does not hurt a suspended actor, in three of three cases. This is the opposite of a node stop. After a stop, the first request binds the actor to a worker of the dead node. After a drain, the node has no worker, and its atelet still answers. So the resume moves the snapshot off the node and places the actor on the other node. In the quiet case the drained node held 52 MiB of snapshots before the request and 1 MiB after it.

Our position: before a drain, suspend each running actor with --mode sync, and then drain. A suspended actor moves with its state at the next request. A running actor crashes, and a revert returns it to the last durable snapshot. Section 8.4 gives the same order for a worker upgrade.

After kubectl uncordon the node stayed empty, in five of five returns. Its pool pods ran on the other node, and Kubernetes did not move them back. We created 15 new actors after each return, in 86 s. None of the 75 went to the returned node. We moved pods by hand again.

A stopped node, and no free worker on the other node

The runbook for a stopped node rests on a free worker on the other node. We filled the other node and ran it again. Each worker of that pool holds 1,984 Mi, and one actor with a 1,900 Mi limit fills it.

We measured two node-stop runs with the other node full, on the same guest (16 vCPU, 23 GB), each once. The revert after UNAVAILABLE was accepted in 2.8 and 3.1 s, and the actor was ACTOR_STATE_SUSPENDED. Each request then waited 5.05 to 5.07 s and got 503 with no worker has room for the actor, in 8 of 8 requests. kubectl ate resume actor failed with ResourceExhausted. create actor --tag was accepted, and a request to the new actor got the same 503. Raw runs

Capacity ended the wait in both runs. In the first run we suspended one actor on the full node, which took 0.6 s. The request then returned 200 in 0.80 s, 1.6 s after that command. In the second run we scaled the pool from 4 workers to 6. The request returned 200 in 1.55 s, 1.7 s after the scale command.

The new actors from the tag answered in 0.64 and 0.63 s. Nothing was lost in either run.

So a full node does not break the runbook. The revert and the rescue from a tag still succeed, and both then wait for a worker. Read no worker has room for the actor after a node loss as a capacity problem, and free a worker or add one. Section 4.3 has the same case with both nodes up.

The object store away

A resume from the node copy does not need the bucket. A synchronous suspend needs it, and so does a resume of an actor whose only copy is in the bucket. We took the bucket away in two ways. In the first, each connection was refused. In the second, packets were dropped, so each call waited.

We measured each case once, on the same guest (16 vCPU, 23 GB), with actors that hold no data in memory. The bucket for these runs was outside the cluster, as section 8.1 describes. Raw runs

Operation while the bucket was awayConnection refusedPackets dropped
A request to a running actor200 in 0.06 snot recorded
A request to a suspended actor whose only copy is in the bucket500 error resuming actor after 4.11 s. The actor is ACTOR_STATE_CRASHED504 after 10.05 s. The actor stays ACTOR_STATE_RESUMING
The same actor after the bucket was back503 on 22 of 22 requests. After one revert, 0.2 s, the request returned 200 in 0.33 s with its state200 in 0.63 s with no command, with its state. The bucket had been away for 137 s
suspend --mode sync of a running actorThe call fails after 4.28 s at the step CallAteletSuspend. The actor is ACTOR_STATE_CRASHEDThe call fails after 126.3 s. The actor is ACTOR_STATE_CRASHED
That actor afterwards503 until a revert. Then 200 from the last durable snapshot, and the 2 calls after it were lostThe same, and the 2 calls after the last durable snapshot were lost
suspend in the default mode0.31 s. The snapshot stays PENDING on the node, and the next request returned 200 in 0.22 snot recorded
create actor from a template, then a request200 in 0.27 s200 in 0.24 s

The row to remember is the synchronous suspend. With the bucket away, the call that you send to protect an actor crashes it. The actor was healthy and RUNNING before the call, and it needed a revert after it. The default suspend was safe in the same minute, because it does not wait for the upload.

The first two rows show a second difference. A refused connection crashed the suspended actor, and it stayed crashed after the bucket was back. A dropped connection left it in ACTOR_STATE_RESUMING, and it answered by itself when the bucket returned.

We measured the same cases again with actors that hold 200 MiB, once each, with the bucket on another guest. The bucket was stopped for 57 s. In a second pass its packets were dropped for 176 s. Raw runs

OperationBucket stoppedPackets dropped
A request to a running actor200, 2 of 2200, 2 of 2
A request to a suspended actor whose only copy is in the bucket500 error resuming actor after 3.87 s, then 503504 after 10.05 s, twice
The same actor after the bucket was back503 with no command. After one revert, 0.2 s, 200 in 2.52 s with its count503 with no command. After one revert, 0.3 s, 200 in 2.74 s with its count
suspend --mode sync of a running actorThe call fails after 1.62 s at the step CallAteletSuspend. The actor is ACTOR_STATE_CRASHEDThe call fails after 103.05 s. The actor is ACTOR_STATE_CRASHED
That actor after a revert200 in 2.50 s. Its count was 5 before the suspend and 4 after, so 2 calls were lost200 in 2.60 s, and 2 calls were lost
suspend in the default mode0.83 s. The snapshot stays PENDING on the node, and the next request returned 200 in 0.39 s0.70 s, and the next request returned 200 in 0.49 s
That actor after the bucket was back200 in 0.40 s from the node copy, with its count200 in 2.41 s from the bucket, with its count
create actor from a template, then a request200 in 0.60 s200 in 0.47 s

Two results hold with data in memory. The synchronous suspend again crashed a healthy actor, and the 2 calls after its last durable snapshot were lost, in 2 of 2 cases. The default suspend lost nothing, in 2 of 2 cases.

One result changed. With packets dropped, the actor whose only copy was in the bucket ended crashed, and it needed a revert. In the first run, the same case with no data in memory recovered with no command. So do not count on that recovery.

Our position: check that the bucket answers before a batch of synchronous suspends, such as the batch before a drain or a worker upgrade. If it does not answer, leave the actors running, or suspend in the default mode and accept the node copy.

What we did not test

Each node-stop case, each drain case, and each bucket case ran once. On Envoy the Postgres outage ran seven times in four setups, and the other two outages twice each. On agentgateway each outage ran once. Every outage ran on one kind node. We did not test egress from an actor during an outage, or a connection that stays open across one.

The node-stop cases have four gaps. A stop here was docker stop, which ends the kubelet and atelet cleanly, so a power loss or a network loss can differ. We did not change the node timers of Kubernetes, use more than two worker nodes, or repeat the cases on agentgateway. We filled the other node by memory, and not by CPU or by a count of actors.

The drain cases have three gaps. The pool had no PodDisruptionBudget. We did not drain the node that runs ate-system. We sent no revert in the first 20 s after a drain.

The bucket cases with data in memory ran once each, with 200 MiB. We did not record when a pending snapshot is uploaded after a stopped bucket returns. We did not find why one running actor stayed at 504 after a database outage. We did not test a database outage with dropped packets.

Symptom, cause, fix

Each Substrate failure that we recorded has a fixed text, and the text, the status code, and the delay together name one cause and one fix.

An agent fails and your client logs a 503. Substrate returns a 503 for a full pool, a crashed actor, a dead process, and an API server that is away. The four cases need four different actions. The status code does not separate them. The body and the delay do.

When you finish this section, you can take an error string from your log, find its row here, and apply the fix. Every string in the tables came from one of our runs, and each row names the section that explains the mechanism. The error reference lists the same strings in alphabetical order.

Start from the status code and the delay

Figure 8.3 sorts the failures of one request to an actor. Measure how long the failed request took before you read the body. A failure in under 0.1 s came from a decision. A failure at 5 s came from a wait that ran out.

Fig. 8.10from a failed request to its causedecision
From a failed request to its cause Six failed requests in rows. Each row gives the status code, the delay, and the body that the client saw, then the cause that our runs proved, then the fix. Two rows have a 500 and four have a 503, and the delay and the body tell them apart. THE CLIENT SAW CAUSE FIX 503 after 5.06 s no worker has room for the actor the pool is full every worker is reserved free a worker suspend, or add workers 503 in 0.07 s AssignWorker prerequisite not met actor is CRASHED only SUSPENDED resumes revert the actor kubectl ate revert actor 500 in 0.08 s error resuming actor lab-c/x1 node copy is gone lost before the upload revert the actor recent calls are lost timeout, then 500 error resuming actor lab-e/e-run Postgres is away the row is not readable restore the database resumes lag 27 s 503 after 3 to 5 s actor lab-e/e-run unavailable no ate-api-server the router got no answer check its pods then check Postgres 503 in 0.06 s upstream connect error reason: connection termination the process exited state stays RUNNING suspend, then revert use --mode sync
The delay and the body of a failed request separate six causes that share the status codes 500 and 503. Read top to bottom. Each row is one failure as the client saw it, then the cause that we proved, then the command that fixes it.

A request to an actor fails

The error bodies that this manual records are from the Envoy dataplane, unless a row says otherwise. On agentgateway the status codes are the same and the bodies are not, as the part after the table explains.

SymptomCode and delayCause that we provedFixSection
actor lab-c/b0-5 unavailable: no worker has room for the actor503 after 5.06 sEach worker of the pool had its CPU reserved by running actors. The router held the request for its 5 s budget.Suspend actors that do no work, or add workers. Retry: a worker that became free 2 s into the wait served the request.4.4, 4.5
The same text, soon after a node restart503 after 5.06 sThe control plane had marked each worker from before the restart unavailable, and the workers had not registered again. The window was 10 to 30 s in our second run.Retry for one minute before you add workers.4.3, 8.2
actor xa/x2 unavailable: no worker has room for the actor, for an actor of a new template that has no golden tag503 after 5.06 sThe workerSelector of the template matched no WorkerPool. Substrate accepted the template, and the text is the text of a full pool.Compare the selector with the labels of the pool. Create a new template with the right labels, because a template cannot change.3.6
504 with an empty body, then actor lab-n/u1 unavailable after 5 to 8 s, with the actor in ACTOR_STATE_RESUMING504 after 10 s, then 503The node of a suspended actor had stopped. The actor stayed bound to that node, in ACTOR_STATE_RESUMING for 5 min 33 s from its first request. It was crashed 5 min 44 s after the stop command. It did not move to another node.Send no revert during the outage: it was refused 19 of 19 times, or it left the actor in ACTOR_STATE_REVERTING. Create a new actor from a tag that you made before the stop, which took 1.2 s. Or bring the node back, then revert.8.2, 4.3
504 with an empty body on the first request to a suspended actor, with the actor in ACTOR_STATE_RESUMING and its node up504 after 10.05 sThe resume took more than 10 s, because the snapshot was large and the bucket was far or slow. Envoy stopped its wait for the router, and the resume went on.Send the request again after a pause, and send no revert. In 32 of 32 cycles a later request returned 200 with the state.4.4, 8.1
504 with an empty body on every request to one running actor after a Postgres outage, while other actors answer504 after 10.01 sNot found. The router logged ResumeActor for the actor and no result. We saw it in 1 of 2 outages of a database on another machine.kubectl rollout restart of the atenet-router Deployment. The next request returned 200 in 0.33 s with the state. A resume command and a suspend did not help.8.2
AssignWorker prerequisite not met for Actor: lab-c/r1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED)503 in 0.07 sThe actor is crashed. Substrate resumes only a suspended actor.kubectl ate revert actor, then send the request again.3.5
error resuming actor lab-c/x1500 in 0.08 sThe snapshot was on one node only, and the node copy was gone. The request moved the actor to crashed.kubectl ate revert actor. The calls since the last durable snapshot are lost.3.4, 3.5
error resuming actor lab-c/r1, soon after a node restart500 in 0.41 sThe request arrived before the workers had registered again. It moved a suspended actor to crashed.kubectl ate revert actor. Hold requests until the workers are active.8.2
error resuming actor lab-e/e-run, after a client timeout500 after 0.3 to 8.3 sPostgres was away. ate-api-server could not read the actor.Restore the database. Requests recover in seconds, resumes in half a minute.8.2
error resuming actor sc-lab/t-128, in a burst, after ate-api-server went to four replicas500 after 0.66 to 1.35 sThe bundled Postgres ran out of its 100 connections. The API server log has FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute (SQLSTATE 53300) or FATAL: sorry, too many clients already. Some actors were crashed.Scale ate-api-server back to two replicas, and revert the crashed actors. We did not test a higher connection limit.4.5
actor lab-e/e-run unavailable, with nothing after it503 after 3 to 5 sThe router got no answer from ate-api-server. We saw this text with ate-api-server away, and also with Postgres away, for a running and for a suspended actor.Check both: that the ate-api-server pods are ready, and that Postgres is up. The text does not separate the two.8.2
upstream connect error or disconnect/reset before headers. reset reason: connection termination503 in about 0.06 sThe process in the sandbox exited. The actor still reads ACTOR_STATE_RUNNING.Suspend with --mode sync, which fails and marks the actor crashed. Then revert.3.5
The same text, on a request that was open, with the actor ACTOR_STATE_SUSPENDED afterwards503 after 2.2 s, at the moment of the suspendSomething suspended the actor during the request. A suspend does not wait for open requests, and the suspend call reports success.Send the request again, and it resumes the actor. Suspend only an actor that is idle.3.3
The same text, with the worker of the actor WORKER_STATE_UNAVAILABLE503 after 0.50 to 5.07 sThe node of a running actor had stopped. The delay separates this row from a process that exited. The actor read ACTOR_STATE_RUNNING for the 10 min of our probe.Send no revert and no delete. A revert failed with while calling atelet Terminate and left the actor in ACTOR_STATE_REVERTING. Create a new actor from an earlier tag, or bring the node back and then revert.8.2, 3.5
AssignWorker prerequisite not met for Actor: lab-n/q2 (got: ACTOR_STATE_REVERTING, want ACTOR_STATE_SUSPENDED)503 in 0.06 sA revert started while the node of the actor was down, and it did not finish.Bring the node back, wait for its atelet, then send the revert again. That worked in 0.2 s in one run. More reverts during the outage all failed, 3 to 28 of them in five runs.3.5, 8.2
The same text with got: ACTOR_STATE_DELETING503 in 0.07 sA delete ran while the node of the actor was down, and it did not finish. A new actor with the same name gets AlreadyExists.Bring the node back. Until then the name is blocked.8.2
ingress failed on agentgateway, with status 500, 504, or 5030.04 to 5.0 sPostgres or ate-api-server was away. On agentgateway the body is the same for both, and for an unknown actor.Check that the ate-api-server pods are ready and that Postgres is up. A running actor answered again 21 s after Postgres was ready.8.2
actor ate-demo-counter/does-not-exist not found on Envoy, ingress failed on agentgateway404 in 0.02 sNo actor has that name in that atespace.Check the ate-target-actor header.6.1

On agentgateway, read the status code and the header

On the agentgateway dataplane, the body does not name the cause. Every ingress failure of Substrate returns the body ingress failed, with one exception. A full pool returns no worker has room for the actor, without the actor ... unavailable: text in front of it.

What you have on agentgatewayWhere to read itWhat it tells you
The status codethe responseThe codes are those of the table above: 404, 503, 504, 500, 401, and 403.
The response header ate-error-reasonthe responseA reason code, when the gateway has one. The code at v1.7.0 sets LOT_FULL for a full parking lot and API_UNAVAILABLE when ate-api-server did not answer.
The log line substrate ResumeActor failedthe log of atenet-routerThe gRPC code and the full message of ate-api-server, such as Actor ate-demo-counter/does-not-exist not found.
The actor statekubectl ate get actorsIt separates a crashed actor from a full pool, as on Envoy.

The header rule is from the code. Our recordings on agentgateway kept the status code and the body, and they did not keep the response headers. The log line is from our run: the gateway logged the full message for the 404 that returned ingress failed. So on agentgateway, match on the status code and the header, and never on the body.

Three rows share the text error resuming actor. Separate them with the actor state. After a lost node copy or an early request after a restart, the actor is ACTOR_STATE_CRASHED. After a database outage a running actor is still ACTOR_STATE_RUNNING, and the next request succeeds.

The process exit is the case that hides longest. The state stays RUNNING, so a list of actors shows no problem.

outputan actor whose process exitedFull log
2026-11-12T11:30:45.229Z	j1	exit-request	200	0.065314	exiting with code 3
2026-11-12T11:30:47.441Z	j1	state	ACTOR_STATE_RUNNING	kind-control-plane	SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:kind-control-plane
2026-11-12T11:30:47.518Z	j1	call	503	0.058459	upstream connect error or disconnect/reset before headers. reset reason: connection termination
Three consecutive lines. The actor j1 received a request that made its process exit with code 3.

We measured this on the x86 server guest (24 vCPU, 47 GB, nested KVM), with three actors. The first read RUNNING in 6 of 6 samples over 14 s, and every request returned this 503. Two more read RUNNING for 20 minutes 36 s, in 81 and 80 samples, and 20 of 20 requests returned this 503. In each case a suspend then failed with workflow failed at step CallAteletSuspend: actor lab-c/j1 crashed, or the same text for the other actor, and only then did the state change. Each dead actor kept one CPU of its worker until that suspend. Raw runs

A process that the kernel killed for memory was different. That actor read ACTOR_STATE_CRASHED at the second sample, 4 s after the kill.

Our position: do not wait for Substrate to report a dead process. Have your harness check two things when it sees this 503 body: the actor reads ACTOR_STATE_RUNNING, and its worker is active. Then the harness runs the suspend and the revert itself. With the worker WORKER_STATE_UNAVAILABLE the node is down, and a revert makes the case worse.

The install fails

SymptomCause that we provedFixSection
unknown flag: --platform, then Error: while building the envoy-dataplane image: exit status 125The install from source builds the Envoy image with docker buildx. The stock Docker package of Ubuntu 24.04 has no buildx plugin.Install from the release images. To build from source, install docker-buildx first.2.1
Error: unknown flag "-A" from kubectl ate get workers -AWorkers are not scoped to an atespace. The command lists the workers of all namespaces by default. Its filters are -n, -l, -a for the workers that host actors of one atespace, and --sandbox-class.Run kubectl ate get workers with no flag.2.2
outputthe source install without buildxFull log
unknown flag: --platform
…
Error: while building the envoy-dataplane image: exit status 125
Two lines from the end of the log. The Docker usage text between them is cut.

The step failed after 163 s, so the error arrives late. The documented list of tools is Go, kubectl, and Docker, and it does not name buildx.

The actor cannot reach a host

SymptomDataplaneCause that we provedFixSection
status: NXDOMAIN for every namebothNo rule of the egress policy names that host. The DNS relay answers only for allowed names.Add a rule with the full host name.6.3
HTTP 403, egress deniedEnvoyThe request went to an address that no rule allows.Add the host to the policy, and call it by name.6.2
HTTP 403, actor egress policy deniedagentgatewayThe same cause. The two dataplanes use different texts.The same fix.6.2
A connection reset, with no HTTP responseEnvoyThe actor has no egress policy at all.Create a policy for the actor.6.2
egress_policy.rules[2].tls_passthrough.ports: Required valueCLIA pass-through rule has no default port.Add ports to the rule.6.2
lacks the metadata.uid and metadata.version preconditionsCLIAn update must name the version that it replaces.Copy both fields from get egress-policy -o yaml.6.2
HTTP 503, upstream connection failed, for a host under an https ruleagentgatewayThe host has a certificate from a private CA. The gateway logged invalid peer certificate: UnknownIssuer.Mount a root file that holds your CA over the root file of the proxy container. Apply it again after each installer run.6.4
HTTP 503, upstream connect error or disconnect/reset before headers. reset reason: remote connection failureEnvoyThe same cause. The Envoy log had CERTIFICATE_VERIFY_FAILED.The same fix.6.4
HTTP 403 on a connection that worked a moment agobothA rule was removed. The next request on a reused connection is refused, about 1 s later on agentgateway and 5 to 9 s later on Envoy.Put the rule back, or accept the refusal.6.6
The upstream still gets the old secret after you changed the Kubernetes SecretagentgatewayThe gateway keeps a fetched credential for 300 s. The new value arrived after 100.6 to 280.5 s in three changes.Wait 5 minutes, and keep the old value valid at the upstream for that time.6.5
A model call takes 0.6 s more than the same call from a podbothThe client opened a new HTTPS connection under an https rule. The handshake with the interception endpoint took about 0.6 s.Reuse the connection. The same request took 10 to 12 ms on an open one.6.4, 7.2

A blocked host looks like a DNS failure, and that sends people to the wrong team. In the kagent run a short Service name, mock-llm.kagent:8080, returned 403 while the full name of the same Service returned 200. The policy matches the name that the actor asks for, so use the name exactly as the rule has it.

A client cannot connect, or a command is refused

SymptomCause that we provedFixSection
tlsv13 alert certificate requiredThe client sent no certificate to the agentgateway router.Give the client a certificate from the cluster signer.5.1
certificate unknown on Envoy, a reset on agentgatewayThe client identity is not in the ingress allowlist.Add the SPIFFE ID with --ingress-allowed-spiffe-ids.5.1
Error: failed to connect to ate-api-server: no ready api pods in ate-systemNo API pod was ready. In one run Postgres was away. In another the node had restarted, and the CLI printed this text from 18 to 157 s after the restart.Check Postgres before the API server. After a node restart, wait for the API pods.8.2
Unauthenticated desc = invalid bearer tokenThe file of --token-file held a text that is no token, or a Kubernetes token without the audience api.ate-system.svc.Create the token with --audience api.ate-system.svc.5.1
PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aate-users%3Aalice" lacks "can_list_actors" on "atespace:lab-v"The token is valid, and no access policy gives that principal a role that allows the call.Add the principal to the access policy of the atespace. A scope of global:root needs a global role.5.2
FailedPrecondition desc = actor must be ACTOR_STATE_SUSPENDED to change its actor template (got: ACTOR_STATE_RUNNING)kubectl ate update actor --template ran against a running actor.Suspend the actor, then update it.3.1
InvalidArgument desc = actor_template.golden_egress_policy: ForbiddenThe template uses a preview feature, and the server flag or the client flag for it is missing. The error does not name the feature.Set --preview with the feature name on the server and pass --preview to kubectl ate.8.5
FailedPrecondition desc = Tag not found, from create actor --templateThe golden tag of the template was deleted with kubectl ate delete tag. get actor-templates still lists the tag id, with an empty ERROR column.Create a new template. Never delete a tag with the scope TAG_SCOPE_PUBLISHED that a template lists.3.4
failed to create tag: ... must be ACTOR_STATE_SUSPENDED to be tagged (got: ACTOR_STATE_RESUMING)The actor is bound to a node that stopped. A tag cannot be made after the fact.Make tags while the actor is suspended and its node is up.8.2
failed to revert actor: ... while calling atelet TerminateThe node of the actor is down. The revert needs atelet on that node, and the actor is now ACTOR_STATE_REVERTING.Send no more reverts. Bring the node back, then revert once.3.5, 8.2
failed to suspend actor: ... MarkSuspending prerequisite not met ... (got: ACTOR_STATE_RESUMING, want ACTOR_STATE_RUNNING), or failed to resume actor: ... worker ... assigned to actor ... is unavailableThe actor is bound to a worker on a node that stopped.No command helps during the outage. Use a tag, or wait for the node.8.2
SSL: no alternative certificate subject name matches target hostname 'atenet-router.ate-system.svc.cluster.local'The client called the router by its long name. The router certificate is valid for the short name.Call atenet-router.ate-system.svc.6.1
Unable to connect to the server: EOFThe Kubernetes API server was away. Actors still served requests.Wait, and probe actors from inside the cluster.8.2
bind: address already in use from the kagent CLIEach CLI call opens its own port-forward on port 8083. Calls that start together collide.Keep one port-forward open, and point the CLI at it.7.1
outputthe kagent CLI when several calls start togetherFull log
… Error listen tcp4 127.0.0.1:8083: bind: address already in use unable to create listener: Error listen tcp6 [::1]:8083: bind: address already in use]
error: unable to listen on any of the requested ports: [{8083 8083}]): exit status 1
The first line of the error is cut to its end. The same error occurred in five of eight calls that started together.

We measured the kagent CLI on an x86 server guest (16 vCPU, 31 GB), in the kagent run. With one standing port-forward no call failed. A Session create took 0.17 s at the median (n=32), where it had taken 0.39 s (n=20) with a port-forward for each call. Raw runs

When no row matches

Collect four things before you ask for help or open an issue. Take the status code, the seconds, and the body of the failed request. Take the actor as kubectl ate get actor -o yaml prints it, which includes the state and each snapshot. Take the lifecycle log from kubectl ate logs actor --source lifecycle. Take the worker list from kubectl ate get worker, which shows the room in each pool.

Upgrade, and advice that no longer works

No release before 1.0.0 upgrades in place, and most instructions written for those releases fail at v1.0.0 in a way that this section names.

You search for an error and find a tutorial from three months ago. Its first command runs a script that your checkout does not have. Its second command passes a flag that the CLI rejects. The tutorial was correct for v0.1.0. Agent Substrate changed its installer, its API package, its states, and its request path before 1.0.0, and it kept no compatibility with the old forms.

When you finish this section, you can translate an old instruction into the v1.0.0 form. You can also say which upgrades the project supports, and in which order the components move.

Old instructions, and what they do now

The table lists each old instruction that we found in the v0.1.0 tree or in posts from that time. The middle column is what happens at v1.0.0. The code at the two tags is the source for each row, except where the text says that we ran it.

Old instructionWhat happens at v1.0.0What to do
hack/install-ate-kind.sh --deploy-ate-systemThe script is not in the repository.Run ate-setup deploy ate-system, as section 2.1 shows.
go run ./tools/setup-gcp bootstrapThe tool is not in the repository. It moved to a separate repository for GKE.Follow section 7.3.
kubectl ate create actor --template-ref counterThe flag does not exist.Use --template counter.
kubectl port-forward svc/atenet-router 8000:80, then a Host headerNothing listens on port 80. The router reads the ate-target-actor header and requires a client certificate.Use kubectl ate proxy actor, as section 2.3 shows.
kubectl ate pause actor, PauseActor, ACTOR_STATE_PAUSEDThe command, the method, and the state do not exist.Use kubectl ate suspend actor.
A stored state number such as 7 for crashedThe numbers changed. Crashed is 5, deleting is 6, and reverting is 7.Store the state name, never the number.
apiVersion: ate.dev/v1alpha1 in a WorkerPoolThe CRD serves v1 only, with no conversion. The apply fails.Write ate.dev/v1.
A Go import of pkg/proto/ateapipbThe module is go.ate.dev/substrate, and the package is api/ateapi/v1.Generate the client again from the v1.0.0 proto file.
A gRPC call to /ateapi.Control/GetActorThe service is substrate.ateapi.v1.Control. The old method name reaches nothing.Use a client built from the v1.0.0 proto file.
Template fields snapshotsConfig.onPause and sandboxConfigThe fields do not exist. A template has snapshotConfig.preferredFidelity, and the sandbox class comes from the WorkerPool.Write the template as section 3.1 shows.
A template with no resource limitsThe API requires cpuMillicores and memoryBytes under resources.limits.Set both.
A node label ate.dev/substrate-versionNothing reads the label.Remove the step.
The installer flag --cordon-control-planeThe flag is --cordon-system-infra-components, and the node label changed with it.Use the new flag and the label ate.dev/workloadType=ate-system-infra.

Two rows deserve more than a cell.

The request path changed for security. At v0.1.0 any pod that reached the router Service could call any actor with a Host header. At v1.0.0 the router asks for a client certificate and checks its identity against an allowlist. We probed the old ports on both dataplanes. On agentgateway, ports 80 and 8081 refused the connection. On Envoy they are not in the Service, and the connection timed out.

The two dataplanes differ in what they list. With Envoy, the router Service has ports 443 and 8444 only. The agentgateway overlay adds ports 80 and 8081 to the Service and to the NetworkPolicy. Nothing listened behind them in our install. The gateway configuration that ate-setup installed declared ports 8443 and 8444 only, and the agentgateway process listened on those two. So on agentgateway the Service lists two ports that refuse every connection.

Its fifth port is different: the stats port 15020 answered /metrics to a pod in another namespace, with no credentials. The router and ingress has the port table and that note.

A tutorial that ends with a plain curl to the router cannot work. Do not look for a setting that brings the old path back.

The API renumbered its states. A state name in a response is safe. A number in a database column, a metric label, or an alert rule from before 1.0.0 now means a different state. An old rule that pages on state 7 pages on a revert.

Advice that is stale in a quieter way

Some statements do not fail with an error. They are wrong, and the system runs.

StatementWhere you read itWhat we found
Substrate suspends an actor that has no requests.the quickstart at the tag, and many postsNothing suspends it. The caller must call SuspendActor.
A suspend returns when the snapshot is safe.tutorials for v0.1.0, where it was trueThe default is asynchronous. The snapshot is on one node first.
The agentgateway dataplane accepts any client.a reading of the manifestsThe installer always adds the client certificate check.
A credential provider requires the Envoy dataplane.the help text of the installer flagThe installer accepted the provider with agentgateway, and injection worked.
Upgrade with NEW_RELEASE=v0.4.2.the example in the upgrade page at the tagNo v0.4.2 exists, and the same page excludes every 0.x upgrade.

We measured the first two rows on an x86 server guest (24 vCPU, 47 GB, nested KVM). An actor with no requests for 900 s read RUNNING in 60 of 60 samples. After a default suspend, the snapshots of four actors stayed in the pending state. The upload finished 5 min 1 s to 5 min 11 s after the suspend. Section 3.3 and section 3.4 give both runs in full. Raw runs

Our position: date every Substrate instruction before you follow it. If the text was written before 2026-11-05, check each command against the v1.0.0 help output first. The project made its largest breaks in the three weeks before the release. A post from October can be as wrong as one from July.

Which upgrades the project supports

The upgrade page at the tag gives five cases. The version skew policy that it rests on starts at 1.0.0.

From and toSupportedWhat to do
A later patch of the same minor, such as 1.0.0 to 1.0.2yes, and you can skip patchesFollow the four steps below.
The next minor, such as 1.0 to 1.1yesFollow the four steps below.
Two minors at once, such as 1.0 to 1.2noUpgrade to 1.1 first.
A new majornoInstall again.
Any 0.x release to 1.0.0noInstall again, with a new database.

The last row has a hard cause. The project merged all its schema migrations into one file on 2026-10-23, and ate-api-server refuses a schema that has no version. A database from before that date cannot be upgraded. Your actors and their snapshots from a 0.x cluster do not move to 1.0.0.

The order, and what running actors see

Components move in a fixed order. A component is allowed to be one release newer than the ones after it, and never newer than the ones before it. Figure 8.4 shows the four steps.

Fig. 8.11the four steps of a supported upgradeflow
The four steps of a supported upgrade Four upgrade steps from top to bottom: the podcertificate-controller, atelet on each node, the workers, and then the API server with the controller and the dataplane. Running actors keep running in the first two steps, and in one run suspends and resumes failed for 24 seconds in the atelet step. In the worker step each actor gets SIGTERM, with 30 minutes only if its process survives the signal, and an actor that nothing suspends becomes crashed. The last step migrates the database schema, which a rollback does not undo. WHAT MOVES, IN THIS ORDER WHAT A RUNNING ACTOR SEES 1 podcertificate-controller signs component certificates 2 atelet one node at a time 3 workers 10 percent of a pool in each batch 4 ate-api-server and the rest ate-idp-server, CRDs, ate-controller, router, egress, SandboxConfig nothing the actor keeps running keeps running in our run, suspends and resumes on the node failed for 24 s SIGTERM 30 minutes to suspend, if the process survives it CRASHED if nothing suspends it the schema migrates API calls can be cut once a rollback does not undo the migration
Only the worker step ends running actors, and an actor has 30 minutes there only if its process survives SIGTERM. Read top to bottom. Each row is one step. The left box holds the components that move, and the right box says what a running actor sees.
  1. podcertificate-controller, which signs the certificates that components show each other. Running actors see nothing.
  2. atelet, one node at a time. Running actors keep running. In our two runs, suspends and resumes on the node failed for 24 s and for 34 s.
  3. The workers. You change spec.workerImage of each WorkerPool, and the pool replaces 10 percent of its workers in each batch.
  4. ate-api-server and ate-idp-server, then the CRDs and ate-controller, then the router and the egress gateway, then the SandboxConfig, then the rest of the system bundle.

Step 3 is the one that costs you work. Each actor on an old worker receives SIGTERM. The upgrade page gives it 30 minutes after that, and our run shows that the time exists only if its process survives the signal. Substrate does not suspend it in that time.

If nothing calls SuspendActor, the actor becomes ACTOR_STATE_CRASHED and loses its work since the last snapshot. This is the idle suspend rule again: your harness must act on the signal. A spot instance gives between 15 seconds and two minutes, never 30 minutes.

Step 4 has one action that cannot be undone. The first new ate-api-server pod migrates the database schema, and a rollback leaves the new schema in place. Take a database backup before step 4. You can roll back to the release you came from and no further.

Our position: before any worker upgrade, stop new requests and wait for the open ones. Then suspend every actor with --mode sync yourself. Keep requests stopped until the workers are replaced, because a request resumes an actor. A synchronous suspend puts the snapshot in the bucket before it returns. Then step 3 finds no running actors, and the 30 minutes do not matter.

Two upgrades that we ran: v1.0.0-rc.3 to v1.0.0

No supported older release exists yet, because the policy starts at 1.0.0. The registry holds images for three release candidates and for v1.0.0, and for nothing older. An install of v0.6.0 from the release images failed with a 404 for its first image. So we installed v1.0.0-rc.3 and moved it to v1.0.0 by the steps above, twice, each time on a new cluster. The policy does not cover this path, and the two releases have the same schema version. Figure 8.4b sets the steps of both runs against the request streams.

Fig. 8.12two upgrades from v1.0.0-rc.3 to v1.0.0, against the request streamstimeline
Two upgrades from v1.0.0-rc.3 to v1.0.0, against the request streams Two timelines of the same upgrade on one kind node, run once each. In the first run the loop actor failed 7 of 110 suspend and resume cycles, all in the atelet step, and the RUNNING actor crashed when the worker image changed. In the second run the loop actor failed 10 cycles in a row in the atelet step, then crashed in the worker step and stayed crashed. The RUNNING actor that exits on SIGTERM crashed again, and the one that handles SIGTERM failed 0 of 441 requests. returned 200 failed a control plane step a worker step First run upgrade step 1 2 3 4a 4b 4c 4d 4e we watched the pool for 160 s suspend and resume, one actor in a loop 7 cycles failed, in 24 s of step 2 RUNNING actor that exits on SIGTERM CRASHED after the image change, 503 until a revert Second run upgrade step 1 2 4a 4b 4c 4d 4e 3, the pool rolls suspend and resume, one actor in a loop 10 failed in 34 s of step 2 then CRASHED until a revert RUNNING actor that exits on SIGTERM 118 of 441 failed, CRASHED until a revert RUNNING actor that handles SIGTERM 0 of 441 failed 0 s 60 s 120 s 180 s 240 s 300 s seconds since the start of step 1
In both runs suspends and resumes failed in the atelet step, and a running actor that exits on SIGTERM crashed when the worker image changed. Read each panel left to right. The top row of a panel shows when each step ran. The rows below show each request of a stream, green for 200 and rose for a failure. The second run had a second running actor, which handles SIGTERM.

We measured the first upgrade on the x86 server guest (24 vCPU, 47 GB, nested KVM). It had one kind node and a pool of 8 workers. Three streams ran during it. One suspended and resumed one actor about every 3.5 s. One listed the actors every 2 s. One sent a request each second to a RUNNING actor. Raw runs

We measured a second upgrade on the same guest, once, on a new cluster with the bundled Postgres and a pool of 10 workers. The streams were the same, and the request stream went to two RUNNING actors. The process of one exits on SIGTERM, and the process of the other handles it. Raw runs

StepSeconds, first runSeconds, second runWhat a client saw
1, podcertificate-controller33.919.3nothing
2, atelet30.940.77 suspend and resume cycles in a row failed over 24 s in the first run, and 10 over 34 s in the second. The suspend failed with while getting atelet conn or with Unavailable from atelet, and the request got 503
3, the worker image of the pool0.4 for the patch0.3 for the patchthe RUNNING actor that exits on SIGTERM crashed. In the first run it answered 503 from 1.2 s after the patch. In the second it answered 503 from 18 s, and it was ACTOR_STATE_CRASHED at the sample 22 s after the patch
4a, ate-api-server and ate-idp-server28.445.3nothing
4b, the CRDs and ate-controller12.319.4nothing
4c, the router and the egress gateway21.840.7nothing
4d, the SandboxConfig18.73.6nothing
4e, the system bundle29.536.1nothing

In the first run, step 1 to the end of step 4e took 343 s. That includes 160 s of watching the pool in step 3. The cycle stream had 103 good cycles of 110, and all 7 failures were in step 2. The list stream got an answer 211 of 211 times.

In the second run that time was 346 s, with 135 s of the worker step in it. The list stream got an answer 199 of 199 times. In both runs the schema version did not change, and every Substrate pod ran v1.0.0 at the end.

We made one actor of each kind before each upgrade and called each one after it.

The actor before the upgradeFirst runSecond run
Suspended with --mode sync at count 3200, count 4200, count 4
Suspended in the default mode at count 3200, count 4200, count 4
Suspended at count 5, with a tag200, count 6200, count 6
Created and never run200, count 1200, count 1
A new actor from the golden snapshot that the release candidate made200, count 1200, count 1
A new actor from the tag200, count 6200, count 6
RUNNING during step 3, with a process that exits on SIGTERMACTOR_STATE_CRASHED. 368 of 469 requests got 503. A revert took 0.22 s, and the count went back to 1ACTOR_STATE_CRASHED. 118 of 441 requests got 503. A revert took 0.22 s, and the count started again from the golden snapshot
RUNNING during step 3, with a process that handles SIGTERMnot in this run441 of 441 requests served. A suspend of 0.62 s moved it to a new worker, and it kept its count
In the suspend and resume loop during step 3every cycle of step 3 servedACTOR_STATE_CRASHED 15 s after the patch, when its suspend failed with Unavailable from atelet. It stayed crashed for 395 s, until our revert. One call was lost

So snapshots, tags, and the golden snapshot from the release candidate worked on v1.0.0 in both runs. The losses were in step 3. An actor that exits on SIGTERM crashed each time. An actor whose suspend was in progress when its worker went away crashed once, in the second run. Nothing repaired it until we sent a revert.

The 30 minutes depend on the actor

The upgrade page gives each running actor SIGTERM and then 30 minutes. Our running actor did not get 30 minutes. Its process exits on SIGTERM, so the actor crashed in seconds.

We measured this in a second roll of the same pool, now on v1.0.0, once. Two actors ran. The process of the first exits on SIGTERM, and that actor was ACTOR_STATE_CRASHED at the first sample, 5 s after the patch. The process of the second handles SIGTERM and keeps running. That actor stayed RUNNING and answered 36 of 36 requests in 175 s, while its worker pod was Terminating. Raw runs

We then suspended it with --mode sync in 0.64 s. It resumed on a new worker, and its count continued.

The second upgrade and the rollback below each had the same two actors, once. In both, the actor without a handler crashed, and the actor with a handler served every request and kept its count after a suspend. So the result holds in all our rolls of a pool. An actor without a handler crashed in 4 of 4, and an actor with a handler survived in 3 of 3.

So the window exists only for an actor whose main process survives SIGTERM. A program exits on that signal unless it has a handler.

Our runs also move the suspend earlier than the upgrade page puts it. Suspends and resumes failed for 24 s and for 34 s while atelet restarted on our one node. So stop requests and suspend every actor before step 2, and resume traffic after step 3. The actor that crashed in the loop shows the cost of a suspend that is still in progress when the worker goes away.

A rollback that we ran: v1.0.0 to v1.0.0-rc.3

The upgrade page gives one sentence for a rollback. It is the same list in reverse, from the old release, and an actor that crashes goes back to its last snapshot with kubectl ate revert actor.

We measured one rollback, right after the second upgrade, on the same cluster. We ran it from the checkout of v1.0.0-rc.3 with the configuration that we saved before the upgrade, in the reverse order. Before it started, we reverted the actor that had crashed in the loop. The same three streams ran. Raw runs

StepSecondsWhat a client saw
4d, the SandboxConfig3.5nothing
4c, the router and the egress gateway23.3nothing
4b, the CRDs and ate-controller5.0nothing
4a, ate-api-server and ate-idp-server25.0nothing
3, the worker image of the pool, back to v1.0.0-rc.30.4 for the patchthe actor that exits on SIGTERM answered 503 from 6.4 s after the patch, and 130 of 306 requests failed. A revert took 0.21 s, and its count started again from the golden snapshot. The actor with a handler served 306 of 306
2, atelet12.7one suspend and resume cycle failed
1, podcertificate-controller3.7nothing

We did not run step 4e in reverse. After the rollback every actor returned 200, and each suspended actor kept its count. Every Substrate image was v1.0.0-rc.3, and the schema version had not changed. The list stream got an answer 138 of 138 times.

The rollback cost the same as the upgrade, and in the same step. This pair of releases has no schema change, so our run cannot show what a rollback does to a new schema. The upgrade page says that the new schema stays, and you need the backup from before step 4 for that case.

From v0.6.0: no upgrade, and a reinstall that needs two fixes

The registry has no images for v0.6.0, so we built it from its tag, on a new cluster on the same guest, once. Its README quickstart failed twice. The first deploy stopped at once.

outputthe install command of the v0.6.0 READMEFull log
Error: at least one of ATE_INGRESS_ALLOWED_SPIFFE_IDS, config.ingressAllowedSPIFFEIDs or --ingress-allowed-spiffe-ids must be set, got none
The last useful line of the output.

With an ingress identity added, the deploy took 278 s. The README command for the demo, deploy demo-counter, printed the help and returned 0, and deploy demo counter worked. The README then sends a request through port 80 of the router, and the port-forward failed with Service atenet-router does not have a service port 80. So no request reached the four actors that we made on v0.6.0 by the path of the README.

We then ran the v1.0.0 installer on that cluster, as a direct upgrade. It stopped in 3 s.

outputa direct upgrade from v0.6.0 to v1.0.0Full log
[step]: deploy_crds
[step]: images: pre-built from ghcr.io/agent-substrate/substrate, tag v1.0.0
…
Error: while applying customresourcedefinition/csidriverconfigs.ate.dev: CustomResourceDefinition.apiextensions.k8s.io "csidriverconfigs.ate.dev" is invalid: status.storedVersions[0]: Invalid value: "v1alpha1": missing from spec.versions …
The error line is cut after the first sentence.

The v0.6.0 system kept running. The v1.0.0 CLI could not talk to it: unknown service substrate.ateapi.v1.Control. Requests through kubectl ate proxy of v1.0.0 still reached three of the four actors, on the old system. The upgrade page of v0.6.0 says to install again, so we did that on the same cluster.

StepResult
ate-setup delete all of v1.0.0done in 346 s. It removed every Substrate namespace and volume
deploy v1.0.0failed in 1 s, on the stored version of the sandboxconfigs.ate.dev CRD. That CRD stayed in deletion, because the SandboxConfig gvisor-default kept the finalizer sandboxconfig.ate.dev/workerpool-protection and its controller was gone
remove the finalizer, deploy againfailed after 157 s. atelet stopped with image cache at "/var/lib/ate/image-cache" has layout version "2" and this atelet supports "3"
delete that folder on the node, deploy againdone in 30 s

The new system had no actor and no atespace. Nothing that we made on v0.6.0 survived.

Our position: do not move a 0.x cluster to 1.0. Install 1.0 on a new cluster, create your templates again, and move traffic. If you must reuse the cluster, plan for the finalizer and the image cache, and for an empty system at the end.

What we did not test

We ran two upgrades and one rollback, each once, on one node, and both upgrades started from a release candidate. We did not test an upgrade between two supported releases, a rollback that crosses a schema change, more than one node, or the microVM class. We did not test an upgrade with a Postgres outside the cluster. From v0.6.0 we found no path that keeps an actor.

A new kagent release, v1.0.1, appeared on the day of our kagent run. All our kagent results are from v1.0.0.

Limits at v1.0.0

At v1.0.0 Substrate runs, suspends, and resumes actors on request, and it leaves the suspend decision, pool size, failover, and key rotation to you.

You plan a rollout and write down what the platform will do for you. It will stop idle sessions. It will add capacity under load. It will survive the loss of one pod. For Agent Substrate v1.0.0, each of those three lines is wrong, and the release does not say so in one place.

When you finish this section, you can list what Substrate does not do at v1.0.0. You can also say which of our claims rest on a run, and which parts we did not test.

What Substrate leaves to you

Figure 8.5 sets each job that a platform team expects against the party that does it at v1.0.0.

Fig. 8.13who does each job at v1.0.0comparison
Who does each job at v1.0.0 Six jobs in rows. On the left is what Substrate does at v1.0.0: it suspends on request, keeps a fixed number of workers, marks an actor crashed when its sandbox dies, runs one router and one database, offers key commands, and keeps one ingress allowlist. On the right is what the operator or the harness must add for each. SUBSTRATE DOES YOU ADD SuspendActor when asked no idle timer: RUNNING in 60 of 60 samples the decision to suspend kagent suspends after each turn a fixed number of workers a full pool returns 503 after 5 s an autoscaler on free worker slots, not on CPU use CRASHED when a sandbox dies a process exit leaves the actor RUNNING a probe for dead processes watch for the upstream connect error one router and one Postgres one egress gateway, two ate-api-server failover a database standby, more routers a key pool and three commands add, activate, and remove a JWT key the rotation schedule no component rotates the key one ingress allowlist a listed client reaches every actor isolation between tenants outside Substrate at v1.0.0
Substrate performs the lifecycle operations that you request, and your harness or your platform team must decide when to request them. Read each row left to right. The left column is what Substrate does, and the right column is what you or your harness must add.

No idle suspend

Nothing in Substrate suspends an actor because it has no requests. The code has no idle timer in ate-api-server, in atelet, or in either worker. The worker API has a method for a worker to ask for a suspend, RequestActorSuspend. atelet forwards that call, and a client library for it exists in the repository. No worker at v1.0.0 uses the library, so nothing sends the call.

We measured this on an x86 server guest (24 vCPU, 47 GB, nested KVM). One actor received no request for 900 s, and it read RUNNING in 60 of 60 samples. A running actor reserves its full CPU and memory limit on a worker for all that time. Raw runs

The harness must call SuspendActor. kagent does it after every turn, in 98 of 98 turns in our run, as section 7.1 shows. If you write your own harness, the suspend call is the first thing to build.

No autoscaler, and no overcommit

A WorkerPool has a fixed replicas number. Nothing in the install changes it. The WorkerPool kind has a scale subresource, so an outside autoscaler can change the number, and we did not test one. CPU and memory use are poor signals for it. An actor reserves its limit while it runs, whether or not it works.

Substrate also books each running actor at its full declared limit. A proposal to book less than the limit exists in the public issues, and the maintainers moved it to after the release.

We measured the effect in the kagent run, on a second x86 server guest (16 vCPU, 31 GB). One run started 24 turns at the same time. The single worker reported 16,000 millicores, and each actor reserved 1,000. The worker stopped at 16 running actors, and the other eight turns waited. Raw runs

When a pool is full, a request waits up to 5 s and then gets a 503. Section 4.5 gives the arithmetic for a pool.

No GPU

An ActorTemplate at v1.0.0 can declare two resources: cpuMillicores and memoryBytes. The API has no field for a GPU or for any other device, so we ran no GPU test.

Single-replica components

The code sets these replica counts in the install manifests.

ComponentReplicasWhat stops when the one pod is away
ate-api-server2nothing, while one replica remains
ate-idp-server2nothing, while one replica remains
atenet-router1every request to every actor
atenet-egress1every new outbound connection from an actor
ate-controller1changes to worker pools
podcertificate-controller1new and renewed certificates
bundled Postgres1every request, as section 8.2 measures

The right column is our reading of the request path, except for the Postgres row, which we ran. The router keeps its parked requests in memory, and a graceful shutdown does not drop them. After the stop signal the router keeps the parked requests until each one gets its full budget and a normal answer. The flag --drain-timeout sets that limit, and its default is longer than the parking budget. Only a crash, a forced kill, or a drain past --drain-timeout drops a parked request.

We restarted the router once, as the fix in section 8.2. We did not measure what a restart does to parked requests, and we did not restart the egress gateway.

Crash detection has a gap

Substrate marks an actor crashed when its sandbox dies or when the kernel kills it for memory. It does not notice a process that exits inside a sandbox that still runs. Section 8.3 shows that actor. A design for this detection is open in the public issues.

We measured two such actors on the x86 server guest (24 vCPU, 47 GB, nested KVM). Both read RUNNING for 20 minutes 36 s after the exit, in 81 and 80 samples. All 20 requests to one of them returned 503. Each kept 1,000 millicores of its worker for all that time. The first suspend call failed and marked the actor crashed. We did not watch longer than 20 minutes, and we did not restart the worker. Raw runs

It also does not mark a running actor whose node stops. On a cluster with two worker nodes, that actor read RUNNING for the 10 minutes of our probe. Each of 150 requests got a 503.

An actor does not leave a dead node by itself

A suspended actor is bound to the node of its last suspend, and Substrate v1.0.0 did not release it when that node stopped.

We measured this on the second x86 server guest (16 vCPU, 23 GB), with two worker nodes and one actor for each of four cases. An actor had a complete snapshot in the object store. It stayed in RESUMING on the stopped node for 5 min 33 s from its first request. It was crashed 5 min 44 s after the stop command. The other node had two free workers. A revert during the outage failed or had no lasting effect in 4 of 4 attempts on three actors. Raw runs

The workers of a stopped node were listed as active for 43 to 51 s after the stop command. A new resume can go to a dead node in that time. Kubernetes moved the worker pods to the other node after 5 minutes and did not move them back. Section 4.3 has the timeline.

We measured 13 more node-stop cases on a second x86 server guest (16 vCPU, 23 GB), once each, with the default timers. In none of them did an actor leave the stopped node by itself. One revert freed an actor in one situation only: suspended with --mode sync, no request since the stop, and the workers already marked unavailable. That revert took 7.1 s. In every other situation a revert left the actor in REVERTING until the node was back. A new actor from a tag that existed before the stop served its first request after 1.2 s, in 3 of 3 rescues. Raw runs

So recovery from a node loss is yours to build. Substrate does not move actors, and it does not move pool pods back to a node that returns. Section 8.2 has the runbook.

Access is coarse at the router

The ingress allowlist holds client identities, and each listed client can reach every actor in every atespace. Access policies for each atespace govern the API calls, as section 5.2 explains. They do not govern requests through the router at v1.0.0.

Manual key rotation

The key that signs actor JWTs lives in a pool that an operator edits. The CLI has kubectl ate admin add-jwt-key, activate-jwt-key, and remove-jwt-key, and the documentation gives the waits between them. No component rotates the key on a schedule. Section 5.3 covers the tokens. Our install had one key, and our decoded token named it. We did not run a rotation.

A suspend does not wait for requests

SuspendActor stops the sandbox at once. A request that is open at that moment gets a 503, and the suspend call still reports success. We saw this in both suspend modes, as section 3.3 shows. The caller of the suspend must know that the actor is idle.

Preview features are off

Three features exist behind a server flag and a client flag. All three are off by default.

Preview nameWhat it adds
ExternalVolumesCSI volumes that an actor mounts and that no snapshot includes
GoldenEgressPolicyan egress policy for the actor that builds the golden snapshot
PerTemplateSnapshotLocationa snapshot location in each template, in place of one for the cluster

To use one, start every component with --preview and its name, and pass --preview to kubectl ate. A request without the client flag behaves as if the feature were off. We tried all four combinations with a template that has a goldenEgressPolicy field.

Server --previewkubectl ate --previewResult of create actor-template
emptynoInvalidArgument desc = actor_template.golden_egress_policy: Forbidden
emptyyesthe same error
GoldenEgressPolicynothe same error
GoldenEgressPolicyyesaccepted, and the golden tag was ready within 30 s
outputthe error when one of the two switches is missingFull log
2026-11-12T13:06:36.842Z	on	no-flag	Error: failed to create actor template: rpc error: code = InvalidArgument desc = actor_template.golden_egress_policy: Forbidden 
One line of the trace, with the server flag on and the client flag missing.

We ran each combination once on the x86 server guest. The error does not name the preview feature or the flag, so you must know that the field is a preview field. Once the template existed, kubectl ate get read it without the client flag.

Our test had two limits. We set the flag on ate-api-server only, with an edit of its Deployment, and the documented way sets it on every component through ate-setup. We also did not test that the egress rule of the golden actor works. Every other experiment ran with --preview= empty, so this manual describes none of the three features in use.

Smaller limits that will reach you

  • An open response or pass-through connection outlives its egress rule. Removing a rule stops requests, also on a reused connection, within 9 s. Only a suspend cuts the rest, as section 6.6 shows.
  • A changed Secret takes up to 5 minutes to reach requests on agentgateway. We measured 100.6 to 280.5 s there, and 1.0 to 1.6 s on Envoy. Section 6.5 has the run.
  • The egress gateway trusts public roots only. A host with a certificate from a private CA returned 503 until we replaced the root file in the proxy container. The installer removes that patch. Section 6.4 has it.
  • On agentgateway, the stats port 15020 of the router answers /metrics with no credentials, and the egress gateway logs at debug after every installer run. Section 6.1 and section 6.6 have both.
  • kubectl ate delete tag accepts the golden tag of a template. The template then lists a tag that is gone, and create actor fails with Tag not found. Section 3.4 has that mistake.
  • An actor sees 2 CPUs at every CPU limit, and the limit is a hard cap. Section 4.1 has the measurement.
  • Names are identities. The documentation tells you never to reuse the name of a deleted atespace. A request that was allowed for the old one can apply to the new one.
  • Postgres is the only store. The maintainers declined a storage plugin before the release.
  • A durable directory has no size limit in the API, so one actor can fill the node disk. This comes from an open issue. We did not test it.
  • The gVisor class accepts a limit for each container and enforces only the limit of the actor.

Our position: none of these limits makes v1.0.0 unusable. Together they mean that Substrate is one layer of a platform. Budget engineering time for four things. They are a harness that suspends, an autoscaler that reads free worker slots, a database with failover, and a probe for dead processes. Section 7.4 weighs that cost against the alternatives.

What we did not test

Most measured numbers in this manual came from clusters with one kind node. One cluster had one control plane node and two worker nodes. Every cluster had one of two dataplanes. The object store was in the cluster, except in two experiments. In one it ran outside the cluster on the same guest, and in the other on a second guest of the same server. This is the full list of what we did not run.

AreaNot tested
CloudsGKE, and every other managed Kubernetes service
StorageGoogle Cloud Storage, Amazon S3, a real link with 100 ms of delay to a bucket, more than one cycle of 1,000 MiB with 100 ms on the path between two guests, a managed Postgres, a Postgres outage with dropped packets, the cause of one running actor that stayed at 504 after a Postgres outage, a connection pooler, a restore of either store
Scalemore than 10,750 actor rows in one cluster, more than 450 actors with a snapshot of their own, more than 300 resumes at one time, more than 300 workers on one node, a burst on more than one node, a burst to actors whose snapshot is only in the bucket, what inside the gVisor restores sets about 37 resumes each second, more replicas of ate-api-server with a higher connection limit of Postgres, a third run of 200 and of 300 at one time, more than 24 parallel turns, more than two worker nodes, more than 20 actors across nodes, and the site claims of millions of agents and of 10x density
Sandboxthe microVM class on arm64, with agentgateway, with a resume from the object store, above 500 MiB, and across CPU models. Also images with an init system, non-root images, large root filesystems
Agentsa hosted model, a model on a GPU, or a model large enough for real agent work behind kagent, any real model behind a model gateway, a kagent turn that runs away while a bound is set, the maxCompletionTokens field of a ModelConfig, a streamed model reply through a model gateway, client authentication at a model gateway, the codex and claude harnesses, MCP servers through the egress gateway
Lifecyclean upgrade from v0.6.0 that keeps actors, an upgrade between two supported releases, a rollback across a schema change, an upgrade on more than one node or with the microVM class, a third run of an upgrade step, a drain with a PodDisruptionBudget on the pool, a drain of the node that runs ate-system, a revert in the first 20 s after a drain, the restore of a deleted golden tag, a resume at the two lower fidelities for an application with a long start on this guest
Networka suspend as a cut-off for a tlsPassthrough connection, the step that takes 0.6 s in an intercepted handshake, the log level of atenet-router, a restart of atenet-egress as a way to refresh a credential, the content of every metric label on the agentgateway stats port, port 9902 of the Envoy container
Limitsmemory limits under load, and CPU limits with several busy actors on one worker
Identitythe signature check of an actor JWT with the published key, a key rotation, federation with a second issuer or a cloud provider, a second token issuer for clients
Snapshotswhy the object store copy of the Python actor was half of its node copy, and equal for random bytes
Failurea dead process that stays RUNNING for more than 20 minutes or across a worker restart, the three outages on more than one node, egress during an outage, parked requests during a router restart, an egress restart, the cause of a burst request with no answer in 90 s, the cause of an actor left in ACTOR_STATE_RESUMING after a database outage, a node that is alive and unreachable, a node that loses power or its network, a node loss on the microVM class or on agentgateway, a node that is full by CPU or by a count of actors, a second run of the Kubernetes API outage with resumes and free workers, a pool with spare workers on a third node, other node timers of Kubernetes
Agent Executora hosted or large model in a task, the example agent with a model that can do its work, a node loss under a task, a large or private clone, an MCP server that the image starts as a command, an MCP registry with listed servers, an MCP server outside the cluster, the release v0.3.1 on the Substrate commit that it names
Apple siliconactors above 200 MiB, a resume from the object store
PreviewExternalVolumes and PerTemplateSnapshotLocation on upstream Substrate, the flag on every component, and the behaviour of a golden egress policy

Where a section states a fact from these areas, it comes from the code or the documentation at the tag, and the text says so.

Part 9

Build

Four small programs that you can run show what you must build around Substrate: an idle suspender, a stateful agent, a tool sandbox, and a failure drill.

  1. 9.1An idle suspender
  2. 9.2A stateful agent actor
  3. 9.3A tool sandbox
  4. 9.4A failure drill

An idle suspender

A small proxy that records the last request for each actor and calls SuspendActor after 30 quiet seconds ran three actors in room for two.

You installed Substrate because most of your agents wait most of the time. Then you watch the pool, and every actor that ever got a request is still running. Nothing suspended them, because nothing in Substrate v1.0.0 does that. The missing piece is yours to write, and it is small.

When you finish this section, you can run a program that suspends idle actors. You can also say what signal it uses, what it costs each client, and where the design stops.

Which signal says that an actor is idle

Idle suspender: a program outside Substrate that decides when an actor has had no work for long enough, and calls SuspendActor for it.

The hard part is the signal. We looked for one on the server side and did not find one that fits.

Candidate signalWhere it isWhy it does not fit
request metrics of the routeratenet.router.route.durationActor names are not allowed as metric labels, so it cannot say which actor is quiet
the usage event of each actorate.actor.usage_sampledIt reports CPU time and memory once a minute, and it measures work, not requests
the actor recordkubectl ate get actorIt has a state and an update time, and no time of the last request

So the suspender has to see the requests itself. It sits in the path of the traffic, and the time of the last request is a value in its own memory.

Fig. 9.1the idle suspender in the request pathflow
The idle suspender in the request path A client sends a request to the idle suspender, which forwards it through kubectl ate proxy to atenet-router and the actor. The suspender keeps the time of the last request for each actor. In the recorded run it called SuspendActor for the actor bursty after 31 quiet seconds, and the call took 0.386 seconds. A later request found the actor rare suspended, and the router resumed it in 0.242 seconds. ONE REQUEST client curl idle suspender one Python file kubectl ate proxy one for each actor atenet-router calls ResumeActor THE DECISION last request busy: 2 s ago bursty: 31 s ago rare: suspended writes on each request ate-api-server SuspendActor actor bursty one of two places quiet for 31 s, so suspend: 0.386 s resume, 0.242 s
The suspender forwards each request, keeps the time of the last one for each actor, and calls SuspendActor when an actor is quiet. Read left to right for a request, and top to bottom for the suspend. The table in the middle is the only state of the suspender.

The program

The demo is one Python file of 125 lines with no dependencies. It starts one kubectl ate proxy actor for each actor that you name. It listens on one port and forwards /<actor>/<path> to the proxy of that actor.

bash
python3 suspender.py --atespace demo-idle --idle 30 --listen 8080 busy bursty rare
curl http://127.0.0.1:8080/busy/

One loop makes the decision once a second. An actor is due when it is running, has no open request, and finished its last request at least --idle seconds ago.

python
due = [name for name, actor in actors.items()
       if actor["running"] and actor["open"] == 0 and now - actor["last"] >= args.idle]
for name in due:
    done, seconds = cli("suspend", "actor", name)

A request that arrives during a suspend waits until the suspend ends. The suspender never resumes an actor. The next request goes to the router as before, and the router resumes the actor. The full file is in the idle suspender folder.

Ten minutes with three actors and room for two

The pool in the recording has one worker with two CPUs, and each actor asks for one CPU. So two actors can run at one time. We created three, and sent each a different pattern of traffic.

  • busy gets one request each 5 seconds.
  • bursty gets five requests in 5 seconds, and then nothing for 145 seconds.
  • rare gets one request after 60 seconds and one after 400 seconds.
outputthe first two minutes of the suspender logFull log
2026-11-12T12:50:05	watch	busy	port=18080	running=False
2026-11-12T12:50:05	watch	bursty	port=18081	running=False
2026-11-12T12:50:06	watch	rare	port=18082	running=False
2026-11-12T12:50:09	listen	http://127.0.0.1:8080/<actor>/<path>	idle=30s
2026-11-12T12:50:13	request	bursty	200	0.443	resumed
2026-11-12T12:50:13	request	busy	200	0.487	resumed
2026-11-12T12:50:14	request	bursty	200	0.063	warm
…
2026-11-12T12:50:44	request	busy	200	0.069	warm
2026-11-12T12:50:49	suspend	bursty	idle=31s	0.386	ok
2026-11-12T12:50:49	request	busy	200	0.063	warm
…
2026-11-12T12:51:09	request	busy	200	0.065	warm
2026-11-12T12:51:13	request	rare	200	0.242	resumed
2026-11-12T12:51:15	request	busy	200	0.062	warm
…
2026-11-12T12:51:40	request	busy	200	0.058	warm
2026-11-12T12:51:44	suspend	rare	idle=31s	0.349	ok
2026-11-12T12:51:45	request	busy	200	0.065	warm
The start, the first suspend, the first request to the third actor, and its suspend. Lines of warm requests between them are cut.

Read the fifth field of each request line. The word resumed marks a request that found the actor suspended. At 12:50:49 the suspender suspended bursty after 31 quiet seconds. That freed one of the two places. At 12:51:13 the first request for rare arrived, and the router resumed it into the free place in 0.24 seconds.

Fig. 9.2three actors share two places for ten minutestimeline
Three actors share room for two, for ten minutes A timeline of 600 seconds on the second x86 server guest. The worker has room for two actors of one CPU each. The actor busy gets a request each 5 seconds and runs for the whole time. The actor bursty gets five requests each 150 seconds, and the suspender suspends it 4 times after 30 quiet seconds. The actor rare gets two requests and is suspended 2 times. All 140 requests returned 200. The worker had one CPU booked in 38 of 58 samples and two in the rest. CPU booked of 2 CPUs 2 CPUs, the pool is full busy 118 requests bursty 20 requests rare 2 requests 0 1 2 3 4 5 6 7 8 9 10 minutes from the first request suspend after 30 quiet seconds request that resumed the actor
All 140 requests returned 200, and the worker held one actor for 38 of 58 samples in place of the three that had traffic. Each row is one actor. A green band is the time the actor ran, a dot is one request, and an amber mark is a suspend by the suspender. The top row is the CPU that the worker had booked.

We measured this run on a second x86 server guest (16 vCPU, 31 GB), with the suspender on the same guest as the cluster. In 10 minutes the three actors got 140 requests, and all 140 returned 200. Raw runs

ResultValue
requests that found the actor suspended7 of 140
time of those requests, after the first two0.24 to 0.26 s
time of a request to a running actor, median0.06 s
suspends by the suspender7, each 0.33 to 0.39 s at the CLI. Six came in the 10 minutes, and the seventh came 10 min 34 s after the start
CPU booked on the worker, mean of 58 samples1,345 of 2,000 millicores

The first two resumed requests took 0.44 and 0.49 seconds, because two actors started in the same second. A client that meets a suspended actor pays about 0.2 seconds more than a warm request for this small actor. SuspendActor and ResumeActor shows how that cost grows with memory.

The same traffic without the suspender

We ran the first two minutes again with the suspender in --dry-run mode. It forwards requests and logs each decision, and it suspends nothing.

outputthe request for the third actor without the suspenderFull log
ts	actor	http	seconds	body
…
2026-11-12T13:05:30.579Z	rare	503	5.054876	actor demo-idle/rare unavailable: no worker has room for the actor
The header and the one line for the actor named rare.

After 60 seconds both places were still taken by busy and bursty, and bursty had been quiet for 55 seconds. The request for rare waited the 5 second parking budget and returned 503. Request parking explains that wait.

Limits of the design

  • It sees only its own traffic. A request that reaches the router another way does not reset the timer. Send all traffic through the suspender, or the suspender stops an actor that is in use.
  • Idle means no request, not no work. An actor that computes after it answers looks idle. The usage events can cover that case, at a delay of one minute.
  • One process holds the state. A restart forgets the last request times. The demo then reads the state of each actor and starts its timers again.
  • A request can arrive during a suspend. The demo holds a new request until its suspend call ends, and then forwards it. Your own suspender needs the same rule for each actor. A request that reaches the router another way still gets a 503.
  • The demo uses the CLI. Each suspend starts one process and uses the shared identity of the CLI.

What to change for production

Run the suspender inside the cluster, beside the router. Give it a client certificate with an identity on the ingress allow list, and let it call ate-api-server with its own identity. Then it no longer needs the Kubernetes API server for each request.

Choose the idle time from the resume cost of your actor. With a resume of 0.25 seconds, 30 seconds of idle time is safe. With a resume of 5 seconds, a short idle time makes many users wait, and a long one keeps workers full.

Suspend in the default asynchronous mode, as the demo does. Then read Snapshot fidelity, and where a snapshot lives for what a node loss costs in the first 5 minutes.

A stateful agent actor

An agent that writes each turn to a durable directory keeps its conversation under both snapshot fidelities, and the fidelity decides what else it keeps and how fast it returns.

You have an agent that talks to a model and keeps a conversation. You want to run one copy for each user, and you do not want to pay for a pod while the user reads the answer. The question that decides your design is simple. What does the agent still know after a suspend?

When you finish this section, you can build an agent actor that keeps its conversation across a suspend. You can also choose a snapshot fidelity from a measured cost, and give the agent a model key that it never holds.

The agent

The demo agent is one Python file of 100 lines with no dependencies. It serves three paths, and it handles one chat turn at a time.

  • POST /chat adds your message to the conversation, calls the model, and stores both messages.
  • POST /note keeps a text in a Python variable and in a file under /tmp. Nothing durable holds it.
  • GET /history returns the conversation and five facts about the state of the process.

The agent keeps the conversation in two places. One is a list in process memory. The other is a SQLite file in /data, which the template mounts as a durable directory. When the process starts, it loads the list from the file.

python
def stored_turns():
    with database() as connection:
        return [{"role": role, "content": content}
                for role, content in connection.execute("select role, content from turns order by n")]


memory = stored_turns()
rehydrated = len(memory)

That one load at the start is the whole design. A process that restarts with an empty memory reads its past from the durable directory. The full file is in the stateful agent folder.

Fig. 9.3what the agent holds after a suspend, under each fidelitycomparison
What the agent holds on the first turn after a suspend, under each fidelity Two columns for the same agent on its first turn after a suspend, on the second x86 server guest. Under memory fidelity it holds 6 messages, its note, its file under /tmp, and the same process at 11.9 seconds of age, and the turn took 0.30 seconds. Under volumes fidelity it holds 6 messages because it loaded 4 from SQLite, it lost the note and the file, the process is 0.3 seconds old, and the turn took 5.06 seconds. memory fidelity SNAPSHOT_FIDELITY_MEMORY volumes fidelity SNAPSHOT_FIDELITY_VOLUMES conversation 6 of 6 messages 6 of 6 messages loaded from SQLite at start 0 messages 4 messages note in a variable kept lost file under /tmp kept lost age of the process 11.9 s, same process 0.3 s, new process time of this turn 0.30 s 5.06 s
The conversation returned under both fidelities, and the note, the file under /tmp, and the process itself returned only under memory fidelity. Read the two columns as the same agent after its first suspend. Green rows came back and rose rows did not. The values are from the two recorded sessions.

The model key

The agent sends the text Bearer placeholder in its Authorization header. It has no other key. The real key is a Kubernetes Secret in a namespace that the actor cannot read. The egress gateway replaces the header on the way out.

yamlthe egress policy of the agent actor, as the API stores itFull log
…
rules:
- http:
    effects:
      replaceHeaders:
      - credentialUri: ate-secret://k8s.io/default/demo-agent-secrets/model-key/token
        header: Authorization
        prefix: 'Bearer '
    hostnames:
    - mock-llm.demo-agent.svc.cluster.local
    ports:
      numbers:
      - 80
The rules. The metadata lines above them are cut.

The model in this demo is a mock server in the cluster, not a real model. It answers from the messages that it receives, and it reports which key arrived. In every one of the 16 model calls of our two sessions it reported the real key. Credential injection explains the mechanism and its limits.

Two sessions, one for each fidelity

We made the same template two times. One copy asks for SNAPSHOT_FIDELITY_MEMORY and the other for SNAPSHOT_FIDELITY_VOLUMES. Each session has two turns and a note, then three rounds of suspend, one turn, and one more turn.

outputthe session under memory fidelityFull log
step	http	seconds	reply
turn 1, new actor	200	0.584397	{"reply": "turn 1: you said 'my name is Ada'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 2, "db_turns": 2, "rehydrated_from_db": 0, "note": null, "scratch_file": false, "proces …
note, memory only	200	0.072225	{"memory_turns": 2, "db_turns": 2, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file": true, "process_age_s": 10.8}
turn 2, warm	200	0.112794	{"reply": "turn 2: you said 'what is my name'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 4, "db_turns": 4, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file" …
turn 3, after suspend 1	200	0.297402	{"reply": "turn 3: you said 'message after suspend 1'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 6, "db_turns": 6, "rehydrated_from_db": 0, "note": "remember the number …
turn 3b, warm	200	0.101483	{"reply": "turn 4: you said 'warm message 1'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 8, "db_turns": 8, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file" …
The header, the first three steps, and the two turns after the first suspend. The reply text is cut at the mark.

Under memory fidelity the agent is the same process after the suspend. The note is there, the file under /tmp is there, and process_age_s keeps growing. The agent loaded nothing from SQLite, because it never started again.

outputthe session under volumes fidelityFull log
step	http	seconds	reply
turn 1, new actor	200	5.083124	{"reply": "turn 1: you said 'my name is Ada'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 2, "db_turns": 2, "rehydrated_from_db": 0, "note": null, "scratch_file": false, "proces …
note, memory only	200	0.068069	{"memory_turns": 2, "db_turns": 2, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file": true, "process_age_s": 0.4}
turn 2, warm	200	0.096986	{"reply": "turn 2: you said 'what is my name'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 4, "db_turns": 4, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file" …
turn 3, after suspend 1	200	5.059144	{"reply": "turn 3: you said 'message after suspend 1'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 6, "db_turns": 6, "rehydrated_from_db": 4, "note": null, "scratch_file": …
turn 3b, warm	200	0.093130	{"reply": "turn 4: you said 'warm message 1'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 8, "db_turns": 8, "rehydrated_from_db": 4, "note": null, "scratch_file": false, "process_ag …
The header, the first three steps, and the two turns after the first suspend. The reply text is cut at the mark.

Under volumes fidelity the agent is a new process after each suspend. Its age is 0.3 seconds when it answers. The note and the file are gone. The conversation is complete, because the new process loaded four turns from SQLite. The model still received the first message of the session in each call.

What each fidelity costs

We measured the time of each turn at the client, on a second x86 server guest (16 vCPU, 31 GB), through kubectl ate proxy. Each session has three turns that follow a suspend and four turns on a running actor. The mock model answers at once, so these times hold no model time. Raw runs

Fig. 9.4the time of one turn, by fidelity and by the state of the actorchart
The time of one agent turn, by fidelity and by the state of the actor Seconds for one turn of the agent at the client, on the second x86 server guest with a mock model. On a running actor a turn took 0.09 to 0.11 s under memory fidelity and 0.09 to 0.10 s under volumes fidelity, with 4 turns each. After a suspend a turn took 0.30 to 0.32 s under memory fidelity and 4.50 to 5.06 s under volumes fidelity, with 3 turns each. 0 1 2 3 4 5 6 seconds for one turn, at the client memory fidelity 0.09 to 0.11 s volumes fidelity 0.09 to 0.10 s memory fidelity 0.30 to 0.32 s volumes fidelity 4.50 to 5.06 s a turn on a running actor a turn that resumes the actor 4 turns for each fidelity 3 turns for each fidelity
A turn that resumed the actor took 0.30 seconds under memory fidelity and 4.5 to 5.1 seconds under volumes fidelity. Each dot is one turn. The left group is a turn on a running actor, and the right group is a turn that had to resume the actor first.
TurnMemory fidelityVolumes fidelity
on a running actor0.09 to 0.11 s0.09 to 0.10 s
after a suspend0.30 to 0.32 s4.49 to 5.06 s
first turn of a new actor0.58 s5.08 s

The lifecycle log of the actor shows where the time goes. Under memory fidelity the restore took 0.15 to 0.17 seconds. Under volumes fidelity it took 4.3 to 4.8 seconds, because the sandbox boots the image and starts Python each time. We did not measure which part of that boot is the largest.

This result differs from the kagent run in kagent 1.0 on Substrate, where a resume under volumes fidelity took 0.38 seconds. The kagent harness is a compiled program in another image. Your own image decides this number, so measure it before you choose.

Limits of the demo

  • The model is a mock. A real model adds its own time to each turn.
  • One actor ran in each session. We did not measure many agents that resume at the same time. Sizing a pool has bursts of up to 300 counter actors.
  • SQLite holds each turn after the model answers. A crash between the answer and the write loses that turn.
  • The agent has no authentication. Each caller that can reach the router can read the conversation.

What to change for production

Keep the write to the durable directory even when you choose memory fidelity. A crashed actor returns to its last durable snapshot, and a revert can drop the newest memory. The durable directory is part of that snapshot, so the file goes back with it. A turn is safe only after the next synchronous suspend, or in a store outside the actor.

Choose memory fidelity when a user waits for the first answer after a pause, and your agent holds little memory. Choose volumes fidelity when the agent holds much memory or when you want a clean process after each suspend. For a clean process on every turn, the harness must suspend the actor between turns. Then make the image start fast.

A tool sandbox

An actor with one egress rule, two resource limits, and a revert after each task runs commands that you do not trust, and returns clean in half a second.

Your agent writes a shell command, and something must run it. If that command runs beside your application, one bad line of model output can read your secrets or send them away. You want a place where the command can do its work and nothing else.

When you finish this section, you can run commands that you do not trust inside an actor. You can also prove what the actor can reach, and return it to a clean state after each task.

The sandbox actor

Tool sandbox: an actor whose only job is to run commands for an agent. It has no credential, no route out except the ones you list, and no state that you keep.

The demo actor is 38 lines of Python. It accepts a command, runs it with sh -c in the directory /work, and returns the exit code and the output.

python
task = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
done = subprocess.run(["sh", "-c", task["cmd"]], cwd=WORK,
                      capture_output=True, text=True, timeout=task.get("timeout", 20))
result = {"exit": done.returncode, "stdout": done.stdout[-2000:], "stderr": done.stderr[-1000:]}

Three things around that code make it a sandbox, and Substrate supplies all three. The full files are in the tool sandbox folder.

Fig. 9.5six tasks in one sandbox actor, with a revert after eachflow
Six tasks in one sandbox actor, each followed by a revert Six rows for six commands sent to one sandbox actor on the second x86 server guest, with one egress rule for a package mirror, 500 millicores and 256 MiB. download from the mirror: file saved, exit 0, revert in 0.47 s, 0 files after it. outside host, by name: NXDOMAIN, revert in 0.48 s, 0 files after it. outside host, by IP: 403 from the gateway, revert in 0.50 s, 0 files after it. Kubernetes API, by name: name not resolved, revert in 0.51 s, 0 files after it. 2 s of CPU work: 2.38 s of wall time, revert in 0.48 s, 0 files after it. 640 MiB of memory: 503, actor CRASHED, revert in 0.43 s, 0 files after it. THE COMMAND TRIES WHAT IT GETS REVERT FILES IN /WORK download from the mirror file saved, exit 0 0.47 s 0 outside host, by name NXDOMAIN 0.48 s 0 outside host, by IP 403 from the gateway 0.50 s 0 Kubernetes API, by name name not resolved 0.51 s 0 2 s of CPU work 2.38 s of wall time 0.48 s 0 640 MiB of memory 503, actor CRASHED 0.43 s 0
One download from the allowed mirror passed, three attempts to reach other hosts failed, and each revert returned an empty work directory. Read left to right. Each row is one task, the result the command saw, and the time of the revert that followed. Rose marks a refused or failed task.

The first is the egress policy. The actor has one rule, for one host, and Substrate denies every other destination. In this demo the host is a package mirror inside the cluster.

yamlthe egress policy of the sandbox actorFull log
…
rules:
- http:
    hostnames:
    - pkg-mirror.demo-tools.svc.cluster.local
    ports:
      numbers:
      - 80
The rules. The metadata lines above them are cut.

The second is the pair of limits in the template: 500 millicores and 256 MiB. The third is the golden snapshot of the template. A new actor starts from it.

RevertActor returns an actor to its latest durable snapshot. That snapshot is the golden snapshot only while the actor has never had a suspend of its own made durable. This demo never suspends the actor, so each revert here returns the golden state. An actor that something suspends, such as an idle suspender, gets its own durable snapshot, and a later revert returns to that one.

bash
kubectl ate create actor task-1 -a demo-tools --template sandbox
kubectl ate create egress-policy task-1 -a demo-tools -f egress-policy.yaml
kubectl ate proxy actor task-1 -a demo-tools 8901:80 &
python3 client.py 8901 'wget -q -O hello.txt http://pkg-mirror.demo-tools.svc.cluster.local/hello-1.0.txt'
kubectl ate revert actor task-1 -a demo-tools

Six tasks

We sent six commands to one actor. After each command the script reverted the actor, and then asked it how many files /work holds.

outputthe allowed download, the revert, and the checkFull log
label	phase	state	http	seconds	result
allowed download	task	RUNNING	200	0.54	{"exit": 0, "stdout": "hello package 1.0\nhello.txt\n", "stderr": "", "seconds": 0.17}
allowed download	revert	SUSPENDED	-	0.47	-
allowed download	clean-check	RUNNING	200	0.44	{"exit": 0, "stdout": "0\n", "stderr": "", "seconds": 0.16}
The header and the three lines of the first task.

The download worked, and the command saw the file hello.txt. The revert took 0.47 seconds. The next request resumed the actor from the golden snapshot in 0.44 seconds, and /work held no file.

The next three commands try to reach a destination that the policy does not list.

outputthree ways out, all refusedFull log
send data out by name	task	RUNNING	200	0.20	{"exit": 0, "stdout": "** server can't find example.com: NXDOMAIN\n\nwget_exit=1\n", "stderr": "wget: bad address 'example.com'\n", "seconds": 0.11}
…
send data out by IP	task	RUNNING	200	0.15	{"exit": 0, "stdout": "wget_exit=1\n", "stderr": "wget: server returned error: HTTP/1.1 403 Forbidden\n", "seconds": 0.04}
…
reach the Kubernetes API	task	RUNNING	200	0.13	{"exit": 0, "stdout": "wget_exit=1\n", "stderr": "wget: bad address 'kubernetes.default.svc.cluster.local'\n", "seconds": 0.04}
The task line of tasks 2, 3 and 4. The revert and check lines between them are cut.

A name outside the policy does not resolve. The command gets NXDOMAIN, and wget reports a bad address. That is also true for the Service name of the Kubernetes API. A request to an IP address reaches the egress gateway, and the gateway answers 403. The command had no way to learn if the address exists.

The last two commands test the limits.

outputCPU work and memory above the limitFull log
2 s of CPU work on a 500m limit	task	RUNNING	200	2.63	{"exit": 0, "stdout": "wall_seconds 2.38\n", "stderr": "", "seconds": 2.54}
…
memory hog on a 256 MiB limit	task	CRASHED	503	4.52	upstream connect error or disconnect/reset before headers. reset reason: connection termination
memory hog on a 256 MiB limit	revert	SUSPENDED	-	0.43	-
memory hog on a 256 MiB limit	clean-check	RUNNING	200	0.44	{"exit": 0, "stdout": "0\n", "stderr": "", "seconds": 0.11}
The task line of task 5 and the three lines of task 6.

The memory limit works as a hard stop. A command that allocated 640 MiB ended the whole sandbox. The client got a 503 after 4.52 seconds, the actor was CRASHED, and one revert returned a clean actor.

The CPU line of this demo does not show what it seems to show. The command runs a loop until the process has used 2.0 seconds of CPU time, and it reads that time inside the sandbox. It finished in 2.38 seconds on a limit of 500 millicores. Our first reading was that the limit did not slow the command. That reading was wrong, and a later test supersedes it.

The CPU limit is a hard cap. The worker writes a CPU quota from cpuMillicores into a cgroup for the actor. For this template the quota is 50 ms of CPU time in each 100 ms period. CPU time that a program reads inside a throttled sandbox is too high. So the loop of the demo stopped early, after less real work than 2.0 seconds of one core.

We measured the cap on a second x86 server guest (16 vCPU, 23 GB), with fixed work and three runs for each limit. Work that took 4.9 to 5.4 seconds at 1000 millicores took 12.7 to 13.1 seconds at 500 and 27.1 to 27.6 seconds at 250. The actor used 0.50 host cores at 500 millicores, with 1 thread and with 4. At 250 millicores the program reported 22 to 24 seconds of CPU time for a run that used 6.9 seconds on the host. WorkerPool has the table and the chart. Raw runs

For a tool sandbox this is the result that you want. A busy command cannot take more CPU than its limit, and it cannot starve the next actor on the worker. Measure work in a sandbox with a fixed task and a wall clock, and never with the CPU time that the task reports.

What a revert costs

We measured six reverts on a second x86 server guest (16 vCPU, 31 GB). Each took 0.43 to 0.51 seconds at the CLI. The first request after each revert took 0.44 to 0.46 seconds, which includes the resume from the golden snapshot. A clean sandbox for the next task costs about one second in total. Raw runs

That number depends on the golden snapshot. This actor holds a Python process and no data. SuspendActor and ResumeActor shows how the time grows with memory.

Limits of the demo

  • The actor has no authentication. A caller that can reach it can run commands. Put it behind your agent, and give the router a strict allow list.
  • We did not run a command that starts processes without end.
  • The mirror is one in-cluster server over HTTP. A public package index needs a tlsPassthrough rule, which needs no trust bundle. Use an https rule and the trust bundle only if the gateway must read the request or inject a credential.
  • gVisor is the isolation here. We did not run this demo on the microVM class.
  • A command can fill /work and the root file system up to the memory and disk that the worker allows. We did not test a disk limit.

What to change for production

Use one actor for each task or each user, and delete it at the end. Only a new actor is certain to start from the golden snapshot. A revert returns to the latest durable snapshot of the actor, and a suspend during a task can make the state of that task durable. If anything in your system can suspend a sandbox actor, delete it and create a new one after each task. A new name also leaves no doubt about old state. We did not time a revert against a delete and a create.

Write the egress policy before the first command runs. An actor with no policy can reach nothing, which is the safe default. List package hosts by full name, and never add a rule for a host that also holds your data.

A failure drill

One script causes six failures on a test cluster and prints the symptom, the state, the fix, and the recovery time for each.

Your first crashed actor will arrive at a bad time. A user reports a 503, and you read error texts that you have never seen before. You can see every one of those texts today, on a cluster that nobody depends on, in about one minute.

When you finish this section, you can run the six failures of this manual on your own cluster. You can also compare your recovery times with ours and know which fix belongs to which symptom.

What the drill does

Failure drill: a script that causes each known failure on purpose, on a cluster you can lose, and records what a client sees.

The drill uses one small actor. It counts requests, and it has two extra paths. A request to /exit ends the process. A request to /oom allocates memory until the sandbox stops it. The pool has two workers with room for one actor each, so the pool is easy to fill.

Fig. 9.6six failures, from the symptom to the first good answertimeline
Six failures of the drill, from the symptom to the first good answer Six rows, one for each failure of the drill on the second x86 server guest. Each row shows the time and status code of the first failed request as a rose bar, the fix that we ran with its time, and the time of the first request that returned 200 after the fix. worker pod deleted: 503 in 0.07 s, fix revert in 0.2 s, then 200 in 0.33 s. local snapshot removed: 500 in 0.08 s, fix revert in 0.2 s, then 200 in 0.41 s. process exits: 503 in 0.06 s, fix suspend, then revert in 0.7 s, then 200 in 0.41 s. over its memory limit: 503 in 0.06 s, fix revert in 0.2 s, then 200 in 0.36 s. pool is full: 503 in 5.05 s, fix suspend another actor in 0.4 s, then 200 in 0.32 s. Postgres away 30 s: 503 in 5.05 s, fix Postgres back in 2.1 s, then 200 in 0.90 s. FAILURE FAILED REQUEST FIX FIRST 200 1 worker pod deleted 503 in 0.07 s revert 0.2 s 0.33 s 2 local snapshot removed 500 in 0.08 s revert 0.2 s 0.41 s 3 process exits 503 in 0.06 s suspend, then revert 0.7 s 0.41 s 4 over its memory limit 503 in 0.06 s revert 0.2 s 0.36 s 5 pool is full 503 in 5.05 s suspend another actor 0.4 s 0.32 s 6 Postgres away 30 s 503 in 5.05 s Postgres back 2.1 s 0.90 s bar length is the time to the failed answer, 0 to 5 s
Each of the six failures gave a good answer in under one second after its fix, and only one of them needed two commands. Read each row left to right. The rose mark is the first failed request, the label names the fix, and the green mark is the first good answer after it.

For each case the script prints the same five facts. The symptom is the status code, the time, and the body of the first failed request. The state is what kubectl ate get actor reports. The fix is the command that we ran. The recovery line is the first request that returned 200 after the fix.

bash
export REGISTRY=localhost:5001
export KIND_NODE=kind-control-plane
./drill.sh setup
./drill.sh run | tee my-drill.log

The script is about 200 lines of shell, and the actor is 45 lines of Python. Both are in the failure drill folder. The core of each case is short. This is the first one.

bash
actor d1 8701
call 8701 / > /dev/null
pod=$(K get actor d1 -a "$SPACE" | awk 'NR == 2 { print $5 }' | cut -d/ -f2)
kubectl -n "$SPACE" delete pod "$pod" --grace-period=0 --force
report detect "CRASHED after $(wait_state d1 ACTOR_STATE_CRASHED)"
report symptom "$(call 8701 /)"
K revert actor d1 -a "$SPACE"
report recover "$(call 8701 /)"

The recorded run

We measured one full run on a second x86 server guest (16 vCPU, 31 GB). Substrate came from the release images, with the Envoy dataplane. The run took 70 seconds for the six cases. Raw runs

Three cases end in ACTOR_STATE_CRASHED without help, and one command repairs each of them.

outputa deleted worker pod, a lost snapshot, and an actor over its memory limitFull log
== 1 worker pod deleted under a running actor
  detect    CRASHED after 0.2 s
  symptom   503 in 0.073744 s: actor demo-drill/d1 unavailable: AssignWorker prerequisite not met for Actor: demo-dril …
  state     ACTOR_STATE_CRASHED
  fix       kubectl ate revert actor d1 -a demo-drill (0.2 s)
  recover   200 in 0.327434 s: count=1 process_age_s=27.9
== 2 node-local snapshot removed before the upload
  symptom   500 in 0.078639 s: error resuming actor demo-drill/d2
  state     ACTOR_STATE_CRASHED
  fix       kubectl ate revert actor d2 -a demo-drill (0.2 s)
  recover   200 in 0.413543 s: count=4 process_age_s=32.3 (durable snapshot held count=3)
…
== 4 the actor uses more memory than its limit
  detect    CRASHED after 2.9 s
  symptom   503 in 0.062056 s: actor demo-drill/d4 unavailable: AssignWorker prerequisite not met for Actor: demo-dril …
  state     ACTOR_STATE_CRASHED
  fix       kubectl ate revert actor d4 -a demo-drill (0.2 s)
  recover   200 in 0.364615 s: count=1 process_age_s=47.3 (new actor from the golden snapshot)
Cases 1, 2 and 4. Long lines are cut at the mark.

Read the three recovery lines with care, because each actor returns at a different point. The actor of case 1 had no snapshot of its own, so it restarts from the golden snapshot with a count of 1. The actor of case 2 had a durable snapshot at a count of 3, and it answers 4. Calls 4 and 5 from before the failure are gone. CRASHED and RevertActor explains that rule.

The third case is the one to practise. The process inside the sandbox ends, and Substrate does not see it.

outputa process that exits leaves the actor in the running stateFull log
== 3 the actor process exits inside the sandbox
  symptom   503 in 0.055202 s: upstream connect error or disconnect/reset before headers. reset reason: connection ter …
  state     ACTOR_STATE_RUNNING (5 s after the exit)
  detect    suspend fails, state ACTOR_STATE_CRASHED
  fix       kubectl ate suspend actor d3, then kubectl ate revert actor d3 (0.7 s)
  recover   200 in 0.408503 s: count=2 process_age_s=41.6 (durable snapshot held count=1)
Case 3. Long lines are cut at the mark.

Five seconds after the exit, the state is still ACTOR_STATE_RUNNING, and each request returns a 503 from the proxy. The drill calls SuspendActor to make Substrate look. That call fails, the state changes to crashed, and the revert then works. The two commands took 0.7 seconds together.

The last two cases are not crashes. No actor needs a revert.

outputa full pool and 30 seconds without PostgresFull log
== 5 more running actors than the pool has room for
  symptom   503 in 5.050146 s: actor demo-drill/d5c unavailable: no worker has room for the actor
  state     d5a ACTOR_STATE_RUNNING, d5b ACTOR_STATE_RUNNING, d5c ACTOR_STATE_SUSPENDED
  fix       kubectl ate suspend actor d5a -a demo-drill (0.4 s)
  recover   200 in 0.321061 s: count=1 process_age_s=58.1
== 6 Postgres is away for 30 s
  symptom   running actor: 503 in 5.055445 s: actor demo-drill/d6 unavailable
  symptom   suspended actor: 503 in 5.053930 s: actor demo-drill/d6s unavailable
  state     kubectl ate get actor fails: Error: failed to connect to ate-api-server: no ready api pods in ate-system
  fix       kubectl -n ate-system scale statefulset postgres --replicas=1 (ready after 2.1 s)
  recover   running actor answers after 0.9 s
  recover   suspended actor answers after 0.4 s
  state     d6 ACTOR_STATE_RUNNING, d6s ACTOR_STATE_RUNNING
total 70 s
Cases 5 and 6. Long lines are cut at the mark.

A full pool gives a 503 after the 5 second parking budget, and a suspend of another actor frees the room. Without Postgres, a running actor and a suspended actor both return 503 after 5 seconds. The CLI fails too, because it finds no Ready ate-api-server pod for its port-forward.

What our run adds to the earlier sections

The drill ran on a different guest and a different install from the runs in Part 3 and Part 8. The symptom texts and the states agree for five of the six cases. The Postgres case does not agree, and you need both sets of symptoms.

The drill, this sectionThe dependency test of When a dependency is down
Machinea second x86 server guest (16 vCPU, 31 GB)a second x86 server guest (16 vCPU, 23 GB)
Postgres away forabout 30 s61 s
Running actor503 after 5.06 s, actor demo-drill/d6 unavailableno answer until the client timeout of 9 s, then 500 error resuming actor, and no 503
Suspended actor503 after 5.05 s, actor demo-drill/d6s unavailable503 after 5 s with the same unavailable text, or 500 error resuming actor
First resume that worked0.4 s after Postgres was ready27 s after Postgres was ready

So a Postgres outage can look like either pattern to a running actor. One is a hang and then a 500. The other is a 503 with unavailable after 5 s, which is also the text of an ate-api-server outage. Check the database and the ate-api-server pods when you see either one.

The two runs differ in the machine, the install, and the length of the outage. We do not compare their seconds, and we did not find which condition changes the symptom. Plan for the longer recovery.

Limits of the drill

  • It needs a kind cluster, because case 2 removes a directory inside the node container.
  • It does not restart a node, and it does not stop ate-api-server or the Kubernetes API server. Part 8 has those runs.
  • It uses one small actor. An actor with more memory needs more time for each resume.
  • It stops Postgres with a scale to zero. A network fault or a slow disk can look different to a client.

What to change for production

Do not run the drill on a cluster with users. Run it on a staging cluster after each Substrate upgrade, and keep the log. A changed symptom text or a new state in that log means that your alerts and your runbook need an update.

Add your own actor to the drill. Replace the image in the template with yours, and check what your application does after a revert to an older snapshot.

Reference

Reference

The methods, objects, states, commands, flags, errors, terms, and sources this manual uses, in one place.

  1. R.1API methods
  2. R.2Objects and fields
  3. R.3States
  4. R.4kubectl ate commands
  5. R.5Flags and preview features
  6. R.6Errors
  7. R.7Glossary
  8. R.8Sources
  9. R.9Index of figures
  10. R.10How this manual was made

API methods

The Substrate API at v1.0.0 is two gRPC services with 50 methods, and four plugin services that a third party can implement.

ate-api-server serves both services on one port, 443 in the Service named api in ate-system. The proto package is substrate.ateapi.v1, so the full name of a method is /substrate.ateapi.v1.Control/GetActor. The Go package is go.ate.dev/substrate/api/ateapi/v1. A client authenticates as section 5.1 describes, and each Control method is checked against the access policies of section 5.2.

The tables give each method, what it does at v1.0.0, and the kubectl ate command that calls it. A dash means that the CLI has no command for it. Our runs called every actor method, the egress policy methods, and the create, get, and list methods for templates, atespaces, and workers. UpdateActor ran in the template move of section 3.1. Our runs also created, replaced, and deleted an atespace access policy and read the global one, in section 5.2.

Our runs created three tags, started three actors from them, and deleted tags, in section 3.4. They did not change the scope of a tag.

Control: actors

MethodWhat it doeskubectl ate
CreateActorCreates an actor from a template or a tag. The new actor is ACTOR_STATE_SUSPENDED.create actor
GetActorReturns one actor with its state and snapshots.get actor <name>
ListActorsLists actors in one atespace or in all.get actor
UpdateActorChanges the template or the worker selector of an actor. Needs the uid and the version.update actor
SuspendActorCheckpoints a running actor to a new snapshot. A suspended actor is returned unchanged. options.mode is asynchronous by default.suspend actor
ResumeActorResumes a suspended actor from its latest snapshot. A running actor is returned unchanged, with resumed=false. The router calls it for every request and relies on that reply.resume actor
RevertActorReturns an actor to ACTOR_STATE_SUSPENDED at its durable snapshot. Accepts a crashed actor, a running actor, and a suspended actor with a node-local snapshot. Also accepts an actor already in ACTOR_STATE_REVERTING, for a retry. In our run that retry worked only after the node of the actor was back. A revert calls atelet on the node that the actor is assigned to. It continues without atelet only for a suspended actor whose latest snapshot is already durable.revert actor
DeleteActorStops the workload, releases the worker, and removes the record. Accepts any state.delete actor
MintActorJwtIssues a token that states the identity of an actor. Only the egress gateway is allowed to call it.-

Control: egress policies

An egress policy is one object under one actor. Section 6.2 explains its rules.

MethodWhat it doeskubectl ate
CreateActorEgressPolicyCreates the policy of an actor.create egress-policy <actor> -f
GetActorEgressPolicyReturns the policy.get egress-policy <actor>
UpdateActorEgressPolicyReplaces the policy. Needs metadata.uid and metadata.version.update egress-policy <actor> -f
DeleteActorEgressPolicyDeletes the policy. The actor returns to default deny.delete egress-policy <actor>

Control: templates, tags, and atespaces

MethodWhat it doeskubectl ate
CreateActorTemplateCreates a template. A reconciler then builds its golden snapshot.create actor-template -f
GetActorTemplateReturns one template with the status of its golden snapshot.get actor-template <name>
ListActorTemplatesLists templates.get actor-template
DeleteActorTemplateDeletes a template, its golden actor, and its golden tag.delete actor-template
CreateTagGives a name to a copy of the durable snapshot of a suspended actor. Uploads the snapshot first if it is not uploaded.create tag --actor
GetTagReturns one tag.get tag <name>
ListTagsLists tags.get tag
UpdateTagChanges the scope of a tag between atespace and published.update tag --scope
DeleteTagDeletes a tag and its snapshot. Actors that still start from the tag cannot resume afterwards. It accepts the golden tag of a template, and create actor for that template then fails with Tag not found.delete tag
CreateAtespaceCreates an atespace.create atespace
GetAtespaceReturns one atespace.get atespace <name>
ListAtespacesLists atespaces.get atespace
DeleteAtespaceDeletes an empty atespace. Fails while an actor or a tag remains.delete atespace

Control: workers

ate-controller and atelet call most of these. An operator reads workers and rarely writes them.

MethodWhat it doeskubectl ate
ListWorkersLists workers with capacity and reserved resources.get worker, top worker
GetWorkerReturns one worker.get worker <name>
ListWorkerActorAssignmentsLists the actors on one worker.-
CreateWorkerRegisters a worker when its pod has an address. The worker starts as unavailable.-
UpdateWorkerUpdates the pool state that the controller observed.-
DeleteWorkerRemoves a worker record.-
DrainWorkerMarks a worker as terminating. The scheduler places no new actor on it. One-way.-
MarkWorkerAvailableMoves an unavailable worker to active.-
MarkWorkerUnavailableMoves an active worker to unavailable, for example while its pod is not ready.-

Control: access policies

MethodWhat it doeskubectl ate
GetGlobalAccessPolicyReturns the one policy for the whole install.get access-policy --global
CreateGlobalAccessPolicyCreates it. The owners named at install can always do this.create access-policy --global -f
UpdateGlobalAccessPolicyReplaces it. Empty bindings remove every grant except those of the install owners.update access-policy --global -f
GetAtespaceAccessPolicyReturns the policy of one atespace.get access-policy -a
CreateAtespaceAccessPolicyCreates it.create access-policy -a -f
UpdateAtespaceAccessPolicyReplaces it.update access-policy -a -f
DeleteAtespaceAccessPolicyDeletes it.delete access-policy -a

The global policy has no delete method.

WorkerService: for atelet only

atelet on each node calls these eight methods. A client of yours never does. They explain log lines and states that you see.

MethodWhat it does
AcquireWorkerEpochGives a worker process a new epoch number when it starts.
RegisterWorkerRecords that the worker process runs under that epoch. Actors from an older epoch become crashed.
SetWorkerSandboxRuntimesReports which sandbox classes and versions the worker can run and restore.
MintWorkerActorCertificateIssues a certificate for a worker that acts for one actor. Its SPIFFE path starts with worker-for-actor, so it is not an identity of the actor itself.
MintWorkerActorJwtIssues a token for a worker that acts for one actor. Its subject is worker-for-actor/<atespace>/<name>, and it speaks for the worker.
RequestActorSuspendAsks the control plane to suspend an actor on behalf of the worker. atelet forwards the call from a worker-side library, and no binary at v1.0.0 imports that library. So nothing triggers it.
ReportActorSnapshotStateReports that a pending snapshot reached the bucket, or that the actor left the node.
ReportActorCrashedReports that a sandbox died. The actor moves from running to crashed.

Plugin services

Substrate calls these services over gRPC. The repository includes one implementation of each, and another party can supply its own.

ServiceMethodsWho calls itPurpose
NodeProviderFetchSnapshot, UploadSnapshotateletmoves snapshot files between a node and the bucket
ControlProviderCleanupSnapshot, CopySnapshotate-api-serverdeletes a snapshot, and copies one for a tag
AssetProviderFetchAssetateletdownloads sandbox runtime files
CredentialProviderFetchSecretthe egress gatewayreturns the secret that replaces a header, as section 6.5 shows

Conventions that every method shares

  • Names: an object is addressed by an atespace and a name. Workers and atespaces have a name only.
  • Preconditions: each object has metadata.uid and metadata.version. An update or a guarded delete that names an old version fails with ABORTED.
  • Wrong state: a lifecycle call from a state that it does not accept fails with FAILED_PRECONDITION. A resume of a crashed actor is the case that we recorded.
  • Lists: list methods take page_size and page_token.
  • Deadline: the server ends any call after 10 minutes.
  • Preview fields: a field behind a preview feature is rejected unless the server enables it and the client sends the header Ate-Preview-Allowed: true.

Objects and fields

Three kinds are Kubernetes objects that describe worker capacity, and every other Substrate object is a record that only the Substrate API returns.

Field names appear here as kubectl ate -o yaml prints them, in camel case. The proto file has the same names in snake case. A field marked preview is rejected unless a preview feature is on, as the flags reference describes.

Kubernetes kinds, group ate.dev/v1

You manage these three with kubectl apply.

WorkerPool (namespaced)

FieldMeaning
spec.replicasthe number of worker pods. Fixed until something changes it.
spec.workerImagethe worker image, which decides the sandbox runtime that the pod starts
spec.sandboxClasses[].namegvisor or microvm
spec.sandboxClasses[].configRef.namea SandboxConfig for that class. Without it the class default applies.
metadata.labelsthe labels of the WorkerPool object. A template selects a pool by these, and each worker of the pool reports them.
spec.template.labelslabels added to the worker Deployment and its pods. No selector of Substrate reads them.
spec.template.resourcesrequests and limits of the worker container. The limits become the capacity that the worker reports.
spec.template.nodeSelector, tolerations, nodeAffinity, priorityClassName, annotations, serviceAccountNamepassed to the worker pods
status.replicas, status.readyReplicasobserved pods
status.sandboxClasses[].conditionsa Resolved condition for each class, with a reason such as SandboxConfigNotFound
yamlthe WorkerPool of the resume experimentFull log
apiVersion: ate.dev/v1
kind: WorkerPool
metadata:
  name: ballast
  namespace: lab-c
  labels:
    workload: ballast
spec:
  replicas: 2
  workerImage: localhost:5001/ateworker-gvisor@sha256:ad6957f41bccaa8bb5f3eb5b34b7812babb862f1a3187a11578c21a7d799491c
  sandboxClasses:
  - name: gvisor
  template:
    resources:
      limits:
        cpu: "2"
        memory: 3Gi
      requests:
        cpu: 500m
        memory: 3Gi
The WorkerPool document. The Namespace document before it is cut.

A worker container without limits reports the whole node as its capacity. Section 4.1 shows that case.

SandboxConfig (cluster)

FieldMeaning
spec.sandboxClassgvisor or microvm
spec.defaultVersionthe version that new actors start on
spec.versions[].namea version name
spec.versions[].stateEnabled or Disabled
spec.versions[].assetsfor each architecture, the files of the runtime with a url and a sha256

The annotation sandboxconfig.ate.dev/is-class-default marks the default configuration of a class.

CSIDriverConfig (cluster, used by a preview feature)

FieldMeaning
spec.driverNamethe CSI driver
spec.controllerEndpointthe address of the CSI controller
spec.nodeSocketOverrideanother path for the node socket
spec.tls.enabled, spec.tls.usePodIdentity, spec.tls.serverNameTLS for the controller connection

Metadata of every API object

FieldMeaning
metadata.atespacethe atespace. Empty for a worker, for an atespace, and for an access policy. The request for an access policy names its atespace in a separate field.
metadata.namethe name, unique in its atespace
metadata.uidassigned by the server. A new object with an old name gets a new uid.
metadata.versiongrows with each write. An update must name the current value.
metadata.createTime, updateTime, deleteTimetimestamps

Actor

FieldMeaning
actorTemplate.atespace, actorTemplate.namethe template of the actor
sourceTagthe tag that the actor started from, if any
workerSelector.matchLabelsnarrows the selector of the template. A pool must match both selectors (logical AND). A change takes effect at the next resume.
status.stateone of seven states, as the states reference lists
status.workerAssignmentpresent while a worker holds the actor: workerPod, workerPool, workerNamespace, workerPodIps, nodeName, workerEpoch, sandboxRuntime
status.assignedNodethe node of the worker while the actor runs, or of the local snapshot after a suspend. It stays until the detach (default 30 minutes, --snapshot-detach-delay) or a revert.
status.snapshots[]the snapshots of the actor, described below
status.lastAssignedGenerationthe last generation that a suspend assigned. A revert does not lower it, so it can be higher than the generation of the newest kept snapshot.
status.crash.message, status.crash.crashTimeset when the actor enters ACTOR_STATE_CRASHED, and cleared by a revert
status.externalVolumespreview: the CSI volumes of the actor

One entry of status.snapshots

FieldMeaning
uuidthe last part of the snapshot address
generationa counter. Each suspend adds one.
ownerSNAPSHOT_OWNER_ACTOR, or SNAPSHOT_OWNER_TAG for a golden or tagged snapshot that the actor borrows
fidelitySNAPSHOT_FIDELITY_VOLUMES, SNAPSHOT_FIDELITY_ROOTFS, or SNAPSHOT_FIDELITY_MEMORY
localitythe node that has a local copy. Absent after the node copy is removed.
durableSnapshot.stateSNAPSHOT_STORAGE_STATE_PENDING until the upload finishes, then SNAPSHOT_STORAGE_STATE_COMPLETED
durableSnapshot.object.snapshotUrithe address in the bucket
sandboxRuntimethe class and the version attributes: architecture, cpu_features, sandbox_bundle_hash
actorTemplateUidthe template that the snapshot was taken under

After an asynchronous suspend an actor has two entries: the older completed one and the new pending one. Section 3.4 follows them.

ActorTemplate

FieldMeaning
workerSelector.matchLabelsselects worker pools by the metadata.labels of the WorkerPool object, in any namespace
containers[]name, image, command, args, env, volumeMounts, securityContext.capabilities, resources
containers[].wakeupProbehttpGet.path, httpGet.port, timeoutSeconds. The resume waits for one success.
volumes[]a name and one source: durableDir, image.reference, systemInfo, or external (preview)
volumes[].systemInfoone source: actorMetadata.items[] or trustBundle. An item has a path and a field: ACTOR_METADATA_FIELD_NAME, ACTOR_METADATA_FIELD_ATESPACE, or ACTOR_METADATA_FIELD_UID.
resources.limits.cpuMillicores, resources.limits.memoryBytesrequired. The worker reserves both while the actor runs.
snapshotConfig.preferredFidelitythe highest fidelity of a suspend. A snapshot can have less, so read the fidelity of the snapshot itself.
snapshotConfig.goldenSnapshotConfig.modeGOLDEN_SNAPSHOT_MODE_ENABLED or GOLDEN_SNAPSHOT_MODE_DISABLED
snapshotConfig.storageLocationpreview: a snapshot location for this template
goldenEgressPolicy.rulespreview: egress rules for the golden actor only
status.goldenSnapshotStatusgoldenTag, snapshotTime, errorMessage
yamlthe template of the 50 MiB ballast actorFull log
metadata:
  atespace: lab-c
  name: ballast-50
workerSelector:
  matchLabels:
    workload: ballast
containers:
- name: ballast
  image: localhost:5001/ballast@sha256:93dba82ad560e7fc4ccb2a33a63d64713b519a7895ba3fd98130ce789eab7fd3
  command:
  - /ballast
  - --mb=50
  wakeupProbe:
    httpGet:
      path: /readyz
      port: 80
resources:
  limits:
    cpuMillicores: 1000
    memoryBytes: 320864256
snapshotConfig:
  preferredFidelity: SNAPSHOT_FIDELITY_MEMORY
The whole file.

EgressPolicy

One policy belongs to one actor. It has metadata and rules[]. Each rule has exactly one of three forms.

Rule formFieldsMeaning
httphostnames, ports, effectsplain HTTP to the named hosts. The port defaults to 80.
httpshostnames, ports, effectsTLS that the gateway intercepts, so it can read and change the request
tlsPassthroughhostnames, portsTLS that the gateway forwards unread. ports is required.

ports is either all or a list in numbers. effects.replaceHeaders[] has header, prefix, and one source: credentialUri for a stored secret, or actorJwt with audiences and expirationSeconds. Section 6.5 shows both sources.

Tag, Atespace, Worker, AccessPolicy

ObjectFieldsMeaning
TagsourceActor, scope, status.snapshota named copy of a snapshot. scope is TAG_SCOPE_ATESPACE or TAG_SCOPE_PUBLISHED.
Atespacemetadata.namea name that groups actors, templates, tags, and one access policy
WorkerworkerNamespace, workerPool, workerPod, workerPodUid, nodeName, labelsone registered worker pod. Its name is opaque, so read the pod from workerPod and workerPodUid. labels is a copy of the metadata.labels of its WorkerPool.
Workerstatus.ipsthe IP addresses of the worker pod, at most one for each IP family
Workerstatus.issuedEpoch, status.registeredEpoch, status.observedEpochthe epoch counters of the worker process. A rise of registeredEpoch means an earlier process exited, and the server then crashes its actors.
Workerstatus.stateWORKER_STATE_ACTIVE, WORKER_STATE_DRAINING, or WORKER_STATE_UNAVAILABLE
Workerstatus.capacity, status.allocatedcpuMillicores, memoryBytes, and actors, as kubectl ate get worker prints them
Workerstatus.sandboxClasses[]the classes and runtime versions that the worker runs and restores
AccessPolicybindings[].role, bindings[].membersgrants of a role to identities, for one atespace or for the whole install

States

An actor has seven states, a snapshot has two storage states, and a worker has three, and each one changes what a request returns.

The names and numbers below come from the proto file at v1.0.0. Store and compare the names. The numbers changed before 1.0.0, as section 8.4 explains.

The "what a request returned" column comes from our runs, through kubectl ate proxy. Most rows are from the x86 server guest (24 vCPU, 47 GB, nested KVM). The rows for a stopped node and for the database outage are from a second x86 server guest (16 vCPU, 23 GB). Those are ACTOR_STATE_RESUMING and ACTOR_STATE_REVERTING in the first table, and the stopped-node rows of the second.

Actor states

NumberStateMeaningWhat a request returned in our runs
1ACTOR_STATE_RESUMINGA worker is bound, and the sandbox is being restored.504 at the client timeout, then 503 actor lab-n/u1 unavailable, for an actor on a stopped node. One other actor stayed in this state after a database outage.
2ACTOR_STATE_RUNNINGThe actor holds a worker, and the API counts it as running. The state does not prove that its process answers.200 in 0.06 to 0.09 s. An actor whose process had exited stayed in this state and returned 503.
3ACTOR_STATE_SUSPENDINGThe worker writes a checkpoint.A request that was open when the suspend started got a 503. A new request in this state was not tested.
4ACTOR_STATE_SUSPENDEDThe actor holds no worker. It has a snapshot, or it never ran.200 in 0.36 s, after a resume
5ACTOR_STATE_CRASHEDThe sandbox or its worker was lost, or a resume failed.503 in 0.06 s, AssignWorker prerequisite not met
6ACTOR_STATE_DELETINGThe workload is being stopped, and the record is removed next.503 in 0.06 to 0.08 s, AssignWorker prerequisite not met
7ACTOR_STATE_REVERTINGThe actor returns to its durable snapshot.503 in 0.06 s, AssignWorker prerequisite not met, after a revert that could not reach the node

Number 0 is ACTOR_STATE_UNSPECIFIED, which no stored actor has. No state means idle, paused, or created. A new actor is suspended.

Fig. R.1the seven actor states, every transition, and what a request returnsstate
The seven actor states, every transition, and what a request returns in each state A reference state figure with the enum number of each state. CreateActor makes an actor in 4 SUSPENDED. ResumeActor moves it through 1 RESUMING to 2 RUNNING, and SuspendActor moves it through 3 SUSPENDING back to SUSPENDED. A failed resume, a lost sandbox, or a failed suspend moves it to 5 CRASHED, where a request returns 503. RevertActor moves a crashed, running, or suspended actor through 7 REVERTING to SUSPENDED. DeleteActor moves an actor in every state to 6 DELETING. Each box says what a request returned in our runs. 4 SUSPENDED holds no worker request: 200 in 0.36 s after a resume 2 RUNNING holds a worker request: 200 in 0.06 to 0.09 s the process exits: still RUNNING, request: 503 3 SUSPENDING writes a checkpoint request: not recorded 1 RESUMING restores the sandbox request: not recorded 5 CRASHED until RevertActor request: 503 in 0.06 s 7 REVERTING last durable snapshot request: not recorded CreateActor SuspendActor checkpoint node or bucket ResumeActor each request ready suspend failed resume failed sandbox lost pod or OOM RevertActor reverted RevertActor RevertActor 6 DELETING stops the workload DeleteActor, in every state the record is removed amber arrow: a state change that our runs show. rose arrow: a failure that our runs show. grey dotted arrow: a transition that we read in the code and did not run. dashed border: a state that a workflow passes through. double border: the record is removed next. A request to a CRASHED actor returns 503 with the body: AssignWorker prerequisite not met. ResumeActor on a CRASHED actor returns FailedPrecondition. SuspendActor on a SUSPENDED actor changes nothing.
A crashed actor returns to service only through RevertActor, and every path into RUNNING goes through ResumeActor. Read from SUSPENDED at the left. The number in each box is the enum value. Each arrow has the call or the event that causes it. Rose arrows are failures. Grey dotted arrows come from the code only.

Transitions

FromToTriggerSeen in our runs
noneSUSPENDEDCreateActoryes, in under 1 s
SUSPENDEDRESUMING, then RUNNINGResumeActor, which the router calls for each requestyes
RESUMINGCRASHEDThe resume failed. In our runs the node-local snapshot was missing, or the worker had not registered after a node restart. The documentation adds a wakeup probe that times out.yes, with a 500 error resuming actor
RUNNINGSUSPENDING, then SUSPENDEDSuspendActoryes
SUSPENDEDSUSPENDEDSuspendActor on a suspended actor returns it unchanged.from the code
SUSPENDINGCRASHEDThe suspend could not record its snapshot.from the code
RUNNINGCRASHEDThe worker pod was deleted.yes, within 0.6 s
RUNNINGCRASHEDThe kernel killed the actor for memory.yes, between 2 s and 4 s
RUNNINGCRASHEDThe node restarted.yes
RUNNINGRUNNINGThe process exited and the sandbox stayed. No transition occurs.yes, for at least 20 min
RUNNINGRUNNINGThe node of the actor stopped. No transition occurs.yes, for at least 10 min
RESUMINGCRASHEDThe node of the actor stopped before or during the resume.yes, 5 min 44 s after the stop command, after 5 min 33 s in RESUMING with requests
RUNNING or CRASHEDREVERTING, then SUSPENDEDRevertActoryes, from CRASHED in 0.22 s
SUSPENDED with a node-local snapshotREVERTING, then SUSPENDEDRevertActor, which discards the copy on the node. The snapshot itself is lost only if its upload is not yet complete.yes, after the node of the actor was back: the next reply had count 4, and the two later calls were lost. With the node down, the actor stayed in REVERTING
REVERTINGREVERTING, then SUSPENDEDA second RevertActor, which continues a revert that stopped part-wayyes, in 0.2 s, after the node and its atelet were back. With the node down, 4 of 4 reverts failed and the state stayed REVERTING
REVERTINGCRASHEDThe node of the actor returned, and we sent no command.yes, in one run, 15 s after the node start. In four runs the actor was still REVERTING after 216 to 223 s
RUNNINGRUNNINGResumeActor on a running actor returns it unchanged, with resumed=false. The router relies on this for every request.yes, on every request to a running actor
anyDELETING, then removedDeleteActoryes

Four rules follow from the table.

  • ResumeActor accepts a suspended actor, an actor that is already resuming, and a running actor, which it returns unchanged. On a crashed actor it returns FAILED_PRECONDITION.
  • RevertActor accepts an actor that is already reverting, so a second revert is the way out of a revert that stopped.
  • A crashed actor never leaves that state by itself. Something must call RevertActor or DeleteActor.
  • A revert returns the actor to its last completed durable snapshot. Calls that came after that snapshot are lost.

Snapshot storage states

StateMeaningHow long, in our runs
SNAPSHOT_STORAGE_STATE_PENDINGThe snapshot is on one node and is not in the bucket.5 min 1 s to 5 min 11 s after an asynchronous suspend, with default timers, for four actors
SNAPSHOT_STORAGE_STATE_COMPLETEDThe snapshot is in the bucket.reached before a synchronous suspend returns

The locality field of a snapshot names the node that still has a local copy. It was set for 30 minutes after the suspend in our run, and absent after that. The owner field is SNAPSHOT_OWNER_ACTOR for a snapshot of the actor itself and SNAPSHOT_OWNER_TAG for a golden or tagged snapshot.

Snapshot fidelity

ValueWhat a resume brings back
SNAPSHOT_FIDELITY_VOLUMESthe durable directories. The processes start again.
SNAPSHOT_FIDELITY_ROOTFSthe durable directories and the changes to the root filesystem. The processes start again.
SNAPSHOT_FIDELITY_MEMORYall of that and the memory of the processes, which continue.

Worker states

StateMeaning
WORKER_STATE_UNAVAILABLERegistered, and the scheduler places no actor on it. A new worker starts here.
WORKER_STATE_ACTIVEThe scheduler places actors on it while it has room.
WORKER_STATE_DRAININGTerminating. No new actor is placed, and bound actors stay until they are suspended. One-way.

kubectl ate get worker prints the state with the actors, the CPU, and the memory that are reserved against the capacity.

Durability modes of a suspend

ValueCLIWhen the call returns
DURABILITY_MODE_ASYNCHRONOUS--mode async, the defaultwhen the snapshot is on the node
DURABILITY_MODE_SYNCHRONOUS--mode syncwhen the new snapshot is in the bucket. An actor that is already suspended is returned as it is, and its snapshot can still be pending on the node.

kubectl ate commands

The kubectl ate plugin at v1.0.0 has 11 verbs over seven resources, and most commands are one call to the Substrate API.

The plugin is the binary kubectl-ate. It finds ate-api-server through your kubeconfig and opens a port-forward for each command, unless you pass --endpoint. Section 2.2 shows how to install and use it. In our runs we built it from the checkout with go install ./cmd/kubectl-ate.

Three kinds of command do more or less than one call. logs actor also reads the logs of the worker pod through the Kubernetes API. proxy actor sends its traffic through atenet-router and not through ate-api-server. The admin commands write Kubernetes Secrets and call no Substrate API.

Global flags

outputthe help text of one command, with the global flagsFull log
Error: unknown flag "-A"

Usage:
  kubectl-ate get worker [worker-name ...] [flags]

Aliases:
  worker, workers

Flags:
  -a, --atespace string        filter worker pods hosting actors in a specific atespace
  -h, --help                   help for worker
  -n, --namespace string       scope output to a specific Kubernetes namespace
      --sandbox-class string   filter by sandbox class (e.g. gvisor, microvm)
  -l, --selector string        filter by worker pool labels

Global Flags:
      --context string      kubeconfig context to use
      --endpoint string     manual override for the gRPC target (e.g., localhost:8080); if omitted, automatically port-forwards
      --kubeconfig string   path to the kubeconfig file
  -o, --output string       output format: table, json, or yaml (default "table")
      --preview             allow the use of preview features enabled on the server
      --token-file string   path to a bearer token for ate-api authentication, or - for stdin (defaults to a Kubernetes ServiceAccount token)
      --trace               enable tracing for the request
  -v, --verbose count       output verbosity; may be repeated (e.g. -v, -vv, -vvvvv)
The output of a wrong command in our first run, kubectl ate get worker -A. The CLI printed the error and then this help.
FlagUse it when
--contextthe machine has more than one cluster. A wrong context sends the command to a cluster with no Substrate.
--endpointyou keep one port-forward open and run many commands
--token-fileyou act as your own identity. Without it the CLI uses a shared ServiceAccount and prints a warning, as section 5.1 explains.
-o yamlyou need the uid, the version, or the snapshots of an object
--previewthe server has a preview feature on and you want to use it
--versionyou want the version of the plugin. It is a flag of the root command only, so the help text of a subcommand above does not list it. It has no -v short form, because -v means --verbose.

Actors

CommandFlagsWhat it does
get actor [name ...]-a, -ALists actors in one atespace or in all, or prints the named ones.
create actor <name>--template, -a, --tagCreates a suspended actor from a template or from a tag. --template takes atespace/name or a name.
update actor <name>-a, --template, --worker-selectorPoints the actor at another template, or changes its worker selector.
suspend actor <name>-a, --modeSuspends. --mode is async by default, or sync.
resume actor <name>-aResumes without a request.
revert actor <name>-aReturns the actor to its durable snapshot.
delete actor <name>-aDeletes the actor in any state.
logs actor <name>-a, -f, -c, --sourcePrints logs. --source is all, containers, or lifecycle. -c _sandbox prints the sandbox runtime log.
proxy actor <name> [[local:]remote ...]-a, --addressOpens a local port that forwards to the actor through the router. Listens on 127.0.0.1 by default.

Templates, tags, and atespaces

CommandFlagsWhat it does
get actor-template [name ...]-a, -ALists or prints templates.
create actor-template -f <manifest>-fCreates one template from a JSON or YAML file, or from standard input with -.
delete actor-template <name>-aDeletes the template and its golden snapshot.
get tag [name ...]-a, -ALists or prints tags.
create tag <name>-a, --actor, --scopeTags the snapshot of a suspended actor. --scope is atespace or published.
update tag <name>-a, --scopeChanges the scope.
delete tag <name>-aDeletes the tag and its snapshot.
get atespace [name ...]noneLists or prints atespaces.
create atespace <name>noneCreates an atespace.
delete atespace <name>noneDeletes an empty atespace.

The CLI has no command that updates a template. To change one, create a template with a new name and point actors at it with update actor --template.

Egress policies and access policies

CommandFlagsWhat it does
get egress-policy <actor>-aPrints the policy of one actor.
create egress-policy <actor> -f <manifest>-a, -fCreates the policy.
update egress-policy <actor> -f <manifest>-a, -fReplaces the policy. The manifest must carry metadata.uid and metadata.version.
delete egress-policy <actor>-a, --uid, --versionDeletes the policy, with optional preconditions.
get access-policy-a or --globalPrints the access policy of an atespace, or the global one.
create access-policy -f <manifest>-a or --global, -fCreates it.
update access-policy -f <manifest>-a or --global, -fReplaces it.
delete access-policy-a, --uid, --versionDeletes the policy of an atespace. The global policy has no delete.

Workers

CommandFlagsWhat it does
get worker [name ...]-n, -a, -l, --sandbox-classLists workers with state, actors, CPU, and memory against capacity.
top worker-n, -a, -l, --sandbox-classPrints the CPU and memory use of workers.

Workers have no atespace. get workers lists the workers of all Kubernetes namespaces by default, and get workers -A fails with unknown flag "-A". The -n and -l flags narrow the list, and -a keeps only the workers that host an actor of that atespace.

Admin commands

These commands read and write Kubernetes Secrets directly. They take --name and --secret-namespace.

CommandFlagsWhat it does
admin make-ca-pool--ca-id, --key-type, --validityCreates a pool with one CA. The key type is ECDSAP256 by default, and the validity is 365 days.
admin make-jwt-pool--alg, --key-idCreates the pool that signs actor JWTs. The algorithm is ES256 by default, or RS256.
admin list-jwt-keysnoneLists the keys of a pool.
admin add-jwt-key--alg, --key-idAdds a key that verifies and does not yet sign.
admin activate-jwt-key--key-idMakes a key the signing key.
admin remove-jwt-key--key-idRemoves a key.

Section 5.3 explains the tokens, and section 8.5 notes that the rotation is manual.

Commands that older text names and v1.0.0 does not have

kubectl ate pause actor and the flag --template-ref do not exist at v1.0.0. Section 8.4 lists the replacements.

Flags and preview features

The flags that change what an actor does are few: two snapshot timers on atelet, the parking budget on the router, and the dataplane and image choice in the installer.

Each table gives the literal flag and the default that the code sets at v1.0.0. Where a recorded argument list shows it, the table also gives the value of our install. A component has more flags than these. The tables hold the ones that this manual uses or that change behaviour you can observe.

The installer, ate-setup

ate-setup reads each setting from a flag, then a configuration file, then an environment variable, then the default.

FlagDefaultWhat it doesSection
--image-repo, --image-tagnoneInstalls published images. Without them the installer builds every image from the checkout. Each flag requires the other.2.1
--kindfalse (environment variable ATE_INSTALL_KIND)Uses the kind overlays: the bundled Postgres, rustfs, and the local registry.2.1
--namespaceate-systemThe namespace of the control plane.2.4
--credential-providernone, requiredA JSON object. {"name":"k8s.io"} installs the provider that reads Kubernetes Secrets. {"enabled":false} turns injection off.6.5
--ingress-allowed-spiffe-idsnoneThe client identities that the router accepts. With no value, no client reaches an actor.5.1
--atenet-dataplaneenvoyenvoy or agentgateway. A second run with the other value switches a live cluster.6.6
--sandbox-classesgvisor,microvmThe sandbox classes to prepare. The default is the same on kind.4.2
--previewemptyThe preview features to turn on in every component.this page
--cluster-sizesize0size10 assumes a PostgreSQL node that you provide. It makes the bundled Postgres request 80 CPUs and 140 GiB of memory, and it changes the database settings to match. It does not create or reserve the node. Do not use it on kind or on a small cluster.8.1
--rollout-timeout60sHow long the installer waits for each workload.2.1
--cordon-system-infra-componentsoffKeeps the system components off the worker nodes. With size10, this flag is what puts Postgres alone on its node.8.4
--ateapi-log-levelnone: keeps the installed level, else infoThe log level of ate-api-server.-
--snapshot-locationnone. The kind profile sets gs://ate-snapshots/.The --snapshot-location of ate-api-server. Required unless PerTemplateSnapshotLocation is on.3.4
--actor-jwt-issuerhttps://idp.<namespace>.svcThe issuer in each actor JWT.5.3
--actor-jwt-algorithmES256The signing algorithm of a new actor JWT key: ES256 or RS256.5.3
--ateapi-authz-bootstrap-ownerssystem:serviceaccount:<namespace>:ate-clientThe --authz-bootstrap-owners of ate-api-server.5.2
--e2e-tuningfalseSets the atelet timers to an upload delay of 2 s and a detach delay of 30 s. It is for test runs.3.4
--setup-csinoneAlso installs a CSI driver: nfs, hostpath, or both.-
--record-dirthe cache directory of your userWhere the installer writes the record of each install.2.4

The help text of --credential-provider says that a provider requires Envoy. In our run the installer accepted {"name":"k8s.io"} with agentgateway, and injection worked.

atelet: when a snapshot becomes durable

FlagDefaultWhat it does
--snapshot-upload-delay5mAfter an asynchronous suspend, how long the snapshot waits on the node before the upload starts.
--snapshot-detach-delay30mHow long a suspended actor stays assigned to its node, with the node copy kept. Must not be shorter than the upload delay.
--snapshot-upload-timeout2mThe limit for one upload attempt, before a size allowance. Each failure in a row doubles the limit of the next attempt, up to 8 times the first.
--snapshot-upload-est-throughput-mib100The size allowance. Each attempt gets the time that the snapshot needs at this rate, in MiB per second, on top of the timeout.
--snapshot-upload-workers2Uploads that run at the same time on one node.
--snapshot-detach-workers2Detaches that run at the same time on one node.
--snapshot-report-workers1Snapshot state reports to ate-api-server that run at the same time.
--snapshot-report-give-up-after10mHow long atelet retries a failed state report.
--snapshot-cache-min-age10mA cached snapshot file younger than this is never removed.
--snapshot-cache-fetch-timeout10mThe limit for the download of one cached snapshot file. A restore that waits for a download that hangs waits this long.
--actor-stats-poll-interval1mHow often atelet samples the resource use of actors.
--drain-delay0How long atelet accepts new calls after SIGTERM.
--drain-timeout5mHow long atelet waits for its work to end when it stops.
--log-levelinfoOur source install ran with debug.
outputthe atelet arguments of our install, default timersFull log
atelet --localhost-registry-replacement=kind-registry:5000 --grpc-server-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem --client-ca-certs=/run/podidentity.podcert.ate.dev/trust-bundle.pem --log-level=debug --preview=
The whole file. No timer flag is present, so the defaults of 5 minutes and 30 minutes apply.

For one experiment we shortened both timers so that a resume from the bucket took seconds to set up. Section 3.3 uses that run.

outputthe atelet arguments with short timersFull log
… --log-level=debug --preview= --snapshot-upload-delay=3s --snapshot-detach-delay=10s
The end of the one line in the file. The first four flags are the same as above.

Short timers move snapshots to the bucket sooner. A resume after the detach delay then reads from the bucket, which is slower. A resume before it still uses the copy on the node. Do not copy the 3 s value into a cluster that serves users.

The router: parking and ports

FlagDefaultWhat it does
--parked-request-budget5sHow long the router holds a request that has no worker yet.
--parked-request-max1024How many requests the router holds at once. 0 turns parking off.
--parked-request-retry-interval100msThe first wait between two tries for a parked request.
--parked-request-retry-factor1.1The growth of that wait.
--parked-request-retry-jitter0.1A random part of each wait, as a fraction between 0 and 1.
--extproc-max-requests0The request limit of the Envoy connection to the router process. 0 means two times --parked-request-max, and at least 1024.
--drain-delay13sHow long the router serves after SIGTERM before it starts to drain.
--drain-timeout0The limit for the drain. 0 means the parking budget plus 10 s plus a margin of 5 s, which is 20 s with defaults. An explicit value must not be less than the parking budget. Parked requests finish inside it.
--route-timeout5mThe limit for one whole request.
--ingress-allowed-spiffe-idsnoneThe allowlist that the installer flag sets.
--port-https8443The listener for requests with the ate-target-actor header.
--port-connect-tls8444The listener for CONNECT tunnels, which kubectl ate proxy uses.
--upstream-connecttrueThe router reaches the worker with CONNECT.
--egress-policy-cache-ttl10sHow long the egress gateway keeps a policy that it read.
outputthe router arguments of our installFull log
atenet-router router --mode=ingress --namespace=ate-system … --port-https=8443 … --port-connect-tls=8444 … --drain-delay=13s --ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client --preview=
Parts of the first line. No parking flag is present, so the budget is the default of 5 seconds.

The 5.05 to 5.16 s in every no worker has room response of our runs is this budget plus the request time. Section 4.4 explains the mechanism.

ate-api-server and ate-controller

ComponentFlagDefaultWhat it does
ate-api-server--snapshot-locationnone. Required unless PerTemplateSnapshotLocation is on.The address prefix for durable snapshots, such as gs://ate-snapshots/. Without it and without that feature, the server does not start.
ate-api-server--postgres-schemasubstrateThe schema that holds every table.
ate-api-server--postgres-pool-max-conns0The size of the read-write connection pool, which the authorizer shares. 0 keeps the default of the connection string or of the driver. The owner, watch, and readiness pools do not follow it.
ate-api-server--template-resync-interval20sHow often the reconciler checks templates and their golden snapshots.
ate-api-server--authz-bootstrap-ownersnone. Required, at least one. ate-setup sets ate-client.Identities that are global owners for as long as the flag lists them, whatever the stored global access policy says.
ate-api-server--actor-jwt-issuernone. Required. ate-setup sets https://idp.ate-system.svc.The issuer in each actor JWT. It must be an https address with no query and no fragment.
ate-api-server--drain-delay, --drain-timeout13s, 15sThe wait before and during the stop of a replica.
ate-controller--dns-egress-policyemptyThe install manifest sets enforce-in-atunnel, so that the DNS relay enforces the egress policy.
ate-controller--worker-sync-concurrency2Worker pods that the controller registers as Worker records at the same time.

Two limits are fixed in the code and have no flag. A worker gives its actors 30 minutes after SIGTERM. ate-api-server ends any call after 10 minutes.

Preview features

A preview feature is off unless two switches agree.

  1. The server switch. Every component takes --preview with a list of names, or ALL. The installer flag passes one value to all of them.
  2. The client switch. A request must carry the header Ate-Preview-Allowed: true. kubectl ate --preview sends it.
NameField that it allowsDefault
ExternalVolumesvolumes[].external in a template, with a CSIDriverConfigoff
GoldenEgressPolicygoldenEgressPolicy in a templateoff
PerTemplateSnapshotLocationsnapshotConfig.storageLocation in a templateoff

The documentation says that an object that was created while a feature was on stays usable after the feature is turned off. That is true for validation only: the server does not check an unchanged value again. The code has two runtime effects that the documentation does not state.

Feature turned offWhat happens to an object that already uses it
ExternalVolumesA resume of an actor that has external volumes fails. The error is external volumes are not supported unless the ExternalVolumes preview gate is enabled.
PerTemplateSnapshotLocationThe server ignores snapshotConfig.storageLocation of the template. New snapshots go to --snapshot-location.
GoldenEgressPolicyWe found no runtime check.

We did not run these two cases. Turn a feature off only after you have removed the objects that use it.

We tried the four combinations of the two switches once, on the x86 server guest. The request was kubectl ate create actor-template with a file that has a goldenEgressPolicy field.

--preview on ate-api-server--preview on kubectl ateResult
emptynot passedError: failed to create actor template: rpc error: code = InvalidArgument desc = actor_template.golden_egress_policy: Forbidden
emptypassedthe same error
GoldenEgressPolicynot passedthe same error
GoldenEgressPolicypassedaccepted, with a golden tag within 30 s

The error names the field and not the feature. A read of the new template with kubectl ate get needed no client flag. In this test only ate-api-server had the server switch, and we restored its arguments afterwards. Every other argument list that we recorded has --preview= with no value, so all three features were off in all other runs.

Kubernetes features that the cluster must have

Substrate issues its certificates with Kubernetes pod certificates. The cluster script for kind turns on three Kubernetes features and one API group for them.

NameKind
ClusterTrustBundleKubernetes feature
ClusterTrustBundleProjectionKubernetes feature
PodCertificateRequestKubernetes feature
certificates.k8s.io/v1beta1API group and version

A cluster without them cannot start the Substrate components. We ran Kubernetes 1.37.0 in every test.

Errors

Every error text that our runs recorded, in alphabetical order, with where it appears, what it means, and the section that explains it.

Search this page for a fragment of the text in your log. Names such as lab-c/r1 are the atespace and the actor of our run, and your text has your own. Section 8.3 gives the fix for each one, grouped by symptom. All strings are from Agent Substrate v1.0.0, except the last table, which is from kagent v1.0.0. One table lists texts that we read in the code and did not record, and it says so.

From a request to an actor

The bodies in this table are from the Envoy dataplane, unless a row names agentgateway. On agentgateway, read the status code and the ate-error-reason response header, as section 8.3 explains.

TextCodeMeaningSection
actor ate-demo-counter/does-not-exist not found404, Envoy routerNo actor has that name in that atespace.6.1
actor lab-c/b0-5 unavailable: no worker has room for the actor503 after 5.06 sNo worker of a matching pool had free CPU and memory inside the parking budget.4.4
actor lab-c/r1 unavailable: AssignWorker prerequisite not met for Actor: lab-c/r1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED)503 in 0.07 sThe actor is crashed, and only a suspended actor can resume.3.5
actor lab-e/e-run unavailable503 after 3 to 5 sThe router got no answer from ate-api-server. We also saw this text while Postgres was down, so check both.8.2
actor lab-n/q2 unavailable: AssignWorker prerequisite not met for Actor: lab-n/q2 (got: ACTOR_STATE_REVERTING, want ACTOR_STATE_SUSPENDED)503 in 0.06 sA revert started while the node of the actor was down, and it did not finish. The state stays until the node is back.3.5, 8.2
actor lab-n/d2 unavailable: AssignWorker prerequisite not met for Actor: lab-n/d2 (got: ACTOR_STATE_DELETING, want ACTOR_STATE_SUSPENDED)503 in 0.07 sA delete started while the node of the actor was down, and it did not finish. The name is blocked until the node is back.8.2
actor lab-n/u1 unavailable503 after 5 to 8 s, after one or two 504s at the client timeout of 10 sThe actor is in ACTOR_STATE_RESUMING on a node that stopped.4.3
actor xa/x2 unavailable: no worker has room for the actor503 after 5.06 sThe workerSelector of the template matched no WorkerPool. The text is the same as for a full pool, and the template has no golden tag.3.6
error resuming actor lab-c/x1500 in 0.08 sThe resume failed because the node-local snapshot was gone. The actor is now crashed.3.5
error resuming actor lab-c/r1500 in 0.41 sThe request arrived soon after a node restart, before the workers had registered. The actor is now crashed.8.2
error resuming actor lab-e/e-run500 after a wait of up to 9 sate-api-server could not read Postgres.8.2
ingress failedany failure code on the agentgateway router: 404, 500, 503, and 504 in our runs, and 401 and 403 in the codeThe agentgateway body for every ingress failure except a full pool. It does not name the cause. We got it for an unknown actor, for a missing routing header, and with Postgres or ate-api-server away. Read the status code and the ate-error-reason header.8.3, 8.2, 6.6
method not allowed405, agentgateway router, port 8444A POST on the port that accepts CONNECT only.6.1
no worker has room for the actor, with no text in front of it503, agentgateway router, from the codeThe full pool on agentgateway. The body has no actor ... unavailable: text in front.4.4
invalid actor reference404, Envoy routerThe ate-target-actor header is not atespace/name.6.1
upstream connect error or disconnect/reset before headers. reset reason: connection termination503 in about 0.06 sThe process in the sandbox exited. The actor still reads RUNNING, for 20 minutes in our longest watch.8.3, 3.5
The same text, after a longer delay503 after 0.50 to 5.07 sThe node of a running actor stopped. The actor still reads RUNNING. All 150 requests of our probe had this delay.8.3, 4.3
The same text, on a request that was open503 after 2.2 sA suspend stopped the actor during the request. The actor reads SUSPENDED, and the next request resumes it.3.3
code 000, no bodyclient timeoutThe request hung. In our runs the cause was Postgres, or the Kubernetes API server for requests through kubectl ate proxy.8.2

Texts from the code that our runs did not record

These bodies are in the router code of the Envoy dataplane at v1.0.0. X is the atespace and the name of the actor.

TextCodeMeaningSection
actor "X" unavailable: router at capacity503The parking lot of the router was full, so the router shed the request after one failed resume attempt.4.4
actor X unavailable: another operation is in progress for this actor503Another workflow held the lease of the actor for the whole parking budget.4.4, 8.1
actor X authentication required401ate-api-server did not accept the identity of the router.5.2
actor X access denied403ate-api-server refused the resume call of the router.5.2
actor X request timed out504The resume did not finish before its deadline.8.2
request for actor X canceled by client408The client closed the request while the router waited. The client does not see this reply, and the router log does.4.4

From a TLS handshake with the router

TextDataplaneMeaningSection
tlsv13 alert certificate requiredagentgatewayThe client sent no certificate.5.1
certificate unknownEnvoyThe client certificate has an identity that the allowlist does not hold.5.1
Connection reset by peeragentgatewayThe same cause as the row above.6.6
SSL: no alternative certificate subject name matches target hostname 'atenet-router.ate-system.svc.cluster.local'bothThe client used the long name of the Service. The router certificate is valid for atenet-router.ate-system.svc.6.1

From inside an actor, on the way out

TextDataplaneMeaningSection
actor egress policy deniedagentgateway, HTTP 403No rule of the egress policy allows the destination.6.2
egress deniedEnvoy, HTTP 403No rule of the egress policy allows the destination.6.2
status: NXDOMAINbothThe DNS relay answers only for names that a rule allows.6.3
curl: (6) Could not resolve host: drip.egress-target.svc.cluster.localbothThe same cause, as curl reports it.6.3
upstream connection failedagentgateway, HTTP 503The host of an https rule has a certificate that the gateway does not trust, such as one from a private CA. The gateway log has invalid peer certificate: UnknownIssuer.6.4
upstream connect error or disconnect/reset before headers. reset reason: remote connection failureEnvoy, HTTP 503The same cause. The gateway log has CERTIFICATE_VERIFY_FAILED.6.4

From kubectl ate and the installer

TextMeaningSection
actor "f-deny" in atespace "lab-f" has no egress policyget egress-policy on an actor that has none. The actor is at default deny.6.2
egress_policy.rules[2].tls_passthrough.ports: Required valueA pass-through rule needs ports.6.2
Error: failed to connect to ate-api-server: no ready api pods in ate-systemNo ate-api-server pod is ready. In one run the cause was Postgres. In another it was a node restart, before ate-api-server was ready.8.2, 4.3
Error: failed to create actor: rpc error: code = AlreadyExists desc = Actor d2 already existsA create with the name of an actor that a failed delete left in ACTOR_STATE_DELETING. Its node was down.8.2
Error: failed to create actor: rpc error: code = FailedPrecondition desc = Tag not foundcreate actor --template for a template whose golden tag was deleted. get actor-templates still lists the tag id.3.4
Error: failed to create tag: rpc error: code = Aborted desc = another operation is in progress for this actorcreate tag on an actor with a resume in progress on a node that had stopped.8.2
Error: failed to create tag: rpc error: code = FailedPrecondition desc = Actor lab-n/t1 must be ACTOR_STATE_SUSPENDED to be tagged (got: ACTOR_STATE_RESUMING)create tag on an actor that is not suspended. We also saw it with got: ACTOR_STATE_CRASHED.3.4, 8.2
Error: failed to create actor template: rpc error: code = InvalidArgument desc = actor_template.golden_egress_policy: ForbiddenThe template has a preview field, and the server flag or the client flag --preview is missing.8.5
Error: failed to list actors: rpc error: code = PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aate-users%3Aalice" lacks "can_list_actors" on "atespace:lab-v"The token is valid, and no access policy allows the call. The same sentence names can_create_actor, can_list_workers, can_list_atespaces, or can_create_access_policy for other calls.5.2
Error: failed to list actors: rpc error: code = Unauthenticated desc = invalid bearer tokenThe token file holds no valid token for the audience api.ate-system.svc.5.1
Error: failed to resume actor: rpc error: code = Unavailable desc = while calling atelet RestoreA resume in the first seconds after the control plane returned from a node restart. The worker had not registered again.4.3
Error: failed to resume actor: rpc error: code = Unavailable desc = worker b17a6492-9643-42f1-bfdb-8bb88ee46772 assigned to actor lab-c/w1 is unavailableA resume a few seconds later. The worker from before the restart was marked unavailable. We got the same text from resume actor for an actor on a node that had stopped.4.3, 8.2
Error: failed to revert actor: rpc error: code = Aborted desc = another operation is in progress for this actorA revert while a resume was in progress on a node that had stopped.3.5
Error: failed to revert actor: rpc error: code = FailedPrecondition desc = Actor lab-n/p3 is not in a revertable state (got: ACTOR_STATE_RESUMING, want one of [ACTOR_STATE_RUNNING ACTOR_STATE_CRASHED]A revert of an actor that stayed in ACTOR_STATE_RESUMING on a stopped node. Our trace cut the end of the text. In the code the text ends with , or ACTOR_STATE_SUSPENDED with a node-local snapshot), which is the third state that a revert accepts.3.5
Error: failed to revert actor: rpc error: code = Unavailable desc = while calling atelet TerminateA revert of an actor whose node had stopped. The actor stayed in ACTOR_STATE_REVERTING, and each later revert during the outage failed with the same text. One more revert worked in 0.2 s after the node and its atelet were back.3.5, 8.2
Error: failed to suspend actor: rpc error: code = FailedPrecondition desc = MarkSuspending prerequisite not met for Actor: lab-n/d2 (got: ACTOR_STATE_RESUMING, want ACTOR_STATE_RUNNING)A suspend of an actor that is not running. Ours was in ACTOR_STATE_RESUMING on a node that had stopped.8.2
Error: failed to suspend actor: rpc error: code = Internal desc = internal server error: workflow failed at step CallAteletSuspend: actor lab-c/j1 crashedA suspend found a dead sandbox. The actor is now crashed.8.3
Error: failed to update actor: rpc error: code = FailedPrecondition desc = actor must be ACTOR_STATE_SUSPENDED to change its actor template (got: ACTOR_STATE_RUNNING)update actor --template on a running actor. Suspend it first.3.1
Error: manifest for actor "f-main" in atespace "lab-f" lacks the metadata.uid and metadata.version preconditions that "get egress-policy -o yaml" printsAn update must name the version that it replaces.6.2
Error: rpc error: code = Unavailable desc = while calling atelet TerminateA delete actor for an actor whose node had stopped. The actor stayed in ACTOR_STATE_DELETING.8.2
Error: unknown flag "-A"get workers has no all-atespaces flag. It lists every namespace by default.2.2
Error: while building the envoy-dataplane image: exit status 125The source install needs docker buildx.2.1
global has no access policyget access-policy --global on a new install. Only the bootstrap owners have a global role.5.2
Unable to connect to the server: EOFkubectl could not reach the Kubernetes API server. Actors still served requests.8.2
unknown flag: --platformDocker has no buildx plugin. This line comes before the build error above.2.1
Warning: no --token-file given; acting as the shared identity system:serviceaccount:ate-system:ate-client rather than as yourself. Pass --token-file to act as yourself.No --token-file was given, so the CLI uses the shared identity.5.1

From the API, as status codes

CodeWhenSection
FAILED_PRECONDITIONa lifecycle call from a state that it does not accept, such as a resume of a crashed actor, or a delete of an atespace that is not emptythe states reference
ABORTEDa write that named an old version, or two workflows on one object. The texts in the code are concurrent update conflict, please retry for an actor or a worker, EgressPolicy version conflict, and another operation is in progress for this actor.the API reference
InvalidArgumenta manifest that fails validation, such as a rule without ports6.2
Internala workflow step failed. The text names the step.8.3

From the kagent CLI

TextMeaningSection
bind: address already in useTwo CLI calls opened a port-forward on port 8083 at the same time.7.1
kubectl port-forward exited before the server became readyThe first line of the same failure.7.1

Glossary

Each term below has one meaning across the manual, and the last column names the section that explains it.

The meanings come from the Agent Substrate source and documentation at v1.0.0 and from our runs. Terms in code font are literal names that you type or read in output.

TermMeaning in this manualExplained in
Access policyA list of bindings that grants roles on the API, for one atespace or for the whole install5.2
ActorOne isolated workload with an identity and a state that Substrate can suspend to a snapshot and resume on a fitting worker. While a node holds its last snapshot, it resumes on that node1.1
Actor JWTA signed token that states the identity of one actor, which the egress gateway can attach to an outbound request5.3
ACTOR_STATE_CRASHEDThe state of an actor whose sandbox, worker, or restore was lost. Only a revert or a delete leaves it.3.5
ActorTemplateThe record that describes the containers, the resource limits, and the snapshot fidelity of the actors made from it3.1
Agent (kagent)The pair of one AgentTemplate and one Harness, which kagent compiles into one ActorTemplate7.1
agentgatewayAn open-source proxy that Substrate can run as its dataplane in place of Envoy6.6
AgentTemplateThe kagent object that holds the model, the prompt, and the tools of an agent7.1
Asynchronous suspendA suspend that returns when the snapshot is on the node, before the upload to the bucket. The default.3.3
ate-api-serverThe Substrate API server. It serves both gRPC services and is the only component that reads and writes Postgres.1.2
ate-controllerThe component that turns each WorkerPool into worker pods and registers them as workers2.4
ate-idp-serverThe component that publishes the public keys for actor JWTs5.3
ate-setupThe installer program in the Substrate repository2.1
ateletThe Substrate agent on each node. It drives the workers of its node and moves snapshots to and from the bucket.2.4
atenet-egressThe egress gateway: the dataplane pod that every outbound connection of an actor passes6.2
atenet-routerThe router: the dataplane pod that receives each request for an actor and forwards it to the worker6.1
AtespaceA name that groups actors, templates, and tags, and that carries one access policy. It is a Substrate record and has no link to a Kubernetes namespace.3.6
Ballast actorOur test actor. It holds a set number of MiB of random bytes and reads one byte of each MiB on a request.3.3
BindingOne role with the identities that hold it, inside an access policy5.2
Bootstrap ownerA principal from the server flag --authz-bootstrap-owners that always holds the global owner role5.2
BucketThe object store location that holds durable snapshots. In our clusters it is ate-snapshots on rustfs.8.1
CrashedThe actor state after a sandbox or a worker is lost, or after a failed resume. Only a revert or a delete leaves it.3.5
Credential injectionThe egress gateway replaces a header of an outbound request with a secret that the actor never holds6.5
Credential providerThe service that returns that secret to the egress gateway6.5
DataplaneThe router and the egress gateway together, built on Envoy or on agentgateway6.6
Default denyAn actor with no matching egress rule reaches no host and resolves no name6.2
DNS relayThe resolver inside the sandbox network that answers only for names that the egress policy allows6.3
Durable snapshotA snapshot whose files are in the bucket, with the storage state completed3.4
EgressPolicyThe rules of one actor that name the hosts and ports it can reach and the headers that the gateway replaces6.2
EpochA number that a worker process gets when it starts. Actors from an older epoch of the same worker become crashed.the API reference
Files-only snapshotA snapshot at volumes fidelity. It keeps the durable directories, and the processes start again at a resume.3.4
First callThe first request to a new actor, which includes a resume from the golden snapshot3.1
Golden snapshotThe snapshot that Substrate takes of one started actor for each template, so that new actors resume from it3.1
Golden tagThe tag under which Substrate stores the golden snapshot of a template3.1
gVisorThe sandbox class that runs an actor under a user-space kernel. The API has no default class: each WorkerPool names exactly one. The pool of the counter demo names gVisor.4.2
HarnessThe runtime that executes an agent and decides when to suspend its actor. In kagent it is also the object that names the WorkerPool and the snapshot location.7.1
Ingress allowlistThe list of client identities that the router accepts5.1
InstallerThe program ate-setup, which installs and removes Agent Substrate from a checkout of the tag2.1
Interception CAThe certificate authority whose key signs the certificates that the egress gateway shows to an actor for an https rule6.4
kagentAn open-source agent framework for Kubernetes. Version 1.0.0 installs its own distribution of Substrate.7.1
kubectl ateThe command-line plugin for the Substrate API2.2
LeaseA row in Postgres that gives one workflow the right to change one object. The workflow renews it, and it expires 30 seconds after its last renewal.8.1
microVMThe sandbox class that runs an actor in a small virtual machine. It needs KVM on the node.4.2
Mock modelThe server in our kagent cluster that answers model calls with a fixed reply, so that model time is near zero7.1
Model gatewayA mode of agentgateway that routes requests to model providers and applies budgets and failover. It is separate from the Substrate dataplane.7.2
Node-local snapshotThe copy of a snapshot on the node disk. After an asynchronous suspend it is the only copy until the upload.3.4
Object storeThe service that holds the bucket: rustfs in our clusters, Google Cloud Storage or S3 in the documentation8.1
Parked requestA request that the router holds open while it retries the resume of the actor4.4
Parking budgetThe time that the router holds a request with no worker before it returns a 503. The default is 5 s.4.4
Placeholder headerThe header that an actor sends with a value that is not a secret, which asks the gateway for the swap6.5
PlacementThe choice of a worker for an actor that resumes4.3
PluginThe binary kubectl-ate, which kubectl runs for each command that starts with kubectl ate2.2
Preview featureA feature that is off unless the server flag --preview and the client header both allow itthe flags reference
PrincipalThe identity that ate-api-server derives from a credential, and the name that access policies grant roles to5.1
Proxykubectl ate proxy actor: a local listener that forwards TCP to one actor through the router2.3
Request parkingThe router holds a request and tries again while no worker has room4.4
ResumeRestore an actor from its latest snapshot on a worker. The router asks for it on every request.3.3
ResumeActorThe call that places a suspended actor on a worker and restores its newest snapshot3.3
RevertReturn an actor to its last durable snapshot and discard what came after it3.5
RevertActorThe call that discards the current run of an actor and returns it to suspended at its last completed durable snapshot3.5
Routing headerThe HTTP header ate-target-actor, with the value <atespace>/<actor>, which tells the router where a request goes6.1
SandboxThe isolated environment that a worker starts for one actor4.2
Sandbox classThe kind of sandbox: gvisor or microvm4.2
SandboxConfigThe Kubernetes object that names the runtime versions of one sandbox class and their files4.2
SessionIn kagent, one conversation. kagent maps each session to one actor.7.1
Shared identityThe ServiceAccount ate-client in ate-system, which kubectl ate uses when you give no token5.1
SnapshotThe saved state of an actor at one suspend3.4
Snapshot cacheA directory on the node that keeps downloaded snapshot files for later resumes8.1
Snapshot fidelityHow much a snapshot holds: volumes only, the root filesystem too, or process memory too3.4
SPIFFE IDThe identity in a client certificate, such as spiffe://cluster.local/ns/ate-system/sa/atenet-client5.1
Standing port-forwardOne kubectl port-forward that you keep open, so that parallel CLI calls do not each open their own7.1
SuspendWrite a snapshot of a running actor and release its worker3.3
SuspendActorThe call that checkpoints a running actor, stops its sandbox, and frees its place on the worker3.3
Synchronous suspendA suspend that returns after the snapshot is in the bucket3.3
TagA named copy of the snapshot of a suspended actor, from which new actors can start3.4, the API reference
TLS interceptionThe egress gateway ends the TLS connection of the actor and opens its own to the host, so that it can read the request6.4
TLS pass-throughThe egress gateway forwards the TLS connection unread, after it checks the server name6.4
Trust bundleThe file that holds the interception CA certificate, so that the actor can verify intercepted connections. A sandbox has it only when its template mounts a systemInfo volume with a trustBundle source. The code in the actor must also be set to trust it.6.4
TurnIn kagent, one user message and the reply to it. kagent resumes the actor before the turn and suspends it after.7.1
Wakeup probeAn HTTP check in a template that a resume waits for before the actor counts as running3.1
Warm callA request to an actor that is already running3.3
WorkerOne pod of a WorkerPool that registered with the API. It hosts actors up to its capacity.4.1
Worker selectorThe labels in a template or an actor that choose which pools can host it4.3
WorkerPoolThe Kubernetes object that asks for a number of worker pods with one image and one resource size4.1

Sources

The manual ranks its sources by authority, with our own recorded runs first, and this page lists each kind and what the sections take from it.

The ranks match how this manual was made. When two sources disagree, the higher one wins and the section says so. Every source was read or recorded on 2026-11-12, one week after the release of Agent Substrate v1.0.0.

The ranked sources

RankSourceWhat the manual takes from it
1our recorded runs on six clusters on four machines: five with Agent Substrate v1.0.0 and one with the Substrate distribution of kagent v1.0.0. Two of the six ran agentgateway v1.7.0every listing, and every number in a paragraph that starts with "We measured"
2the Agent Substrate source code at the release tag v1.0.0every default, state, flag, and mechanism, and the reference tables
3the Agent Substrate documentation at the same taginstall steps, the upgrade order, and documented behaviour that we did not run
4Agent Substrate issues and pull requests on GitHubknown failures, limits, and the history of a feature
5the kagent source code at its tag v1.0.0how a session becomes an actor, and the egress policy that kagent writes
6the agentgateway source code at its tag v1.7.0the Substrate policies of the agentgateway dataplane
7the Agent Executor code and documentation at one commit, built and run with no model and then with a small open model on CPUhow a task becomes an actor in 7.5

Our runs rank above the code on purpose. The code says what the system is built to do. A run says what it did on a real cluster, with the timers, the images, and the installer of the release. Where a run contradicts the documentation, the section reports both.

Rank 1: our recorded runs

Twenty-four experiments, each with its scripts and raw output in the capture folder. How this manual was made describes the machines and the method.

ExperimentWhat it recordedUsed in
A, the quickstart from sourceeach install step with its seconds, and the buildx failurePart 2, 8.3
B, the lifecycle900 s with no request, then suspend, resume, crash, and revertPart 1, Part 3
C, resume against memoryresume and suspend times for actors that hold 0 to 1,000 MiB, from the node and from the bucket, and the disk usePart 3, 8.1
D, durabilitythe snapshot timers, a lost node copy, an atelet restart, and a node restartPart 3, 8.2
E, dependency lossone request each second while Postgres, ate-api-server, and the Kubernetes API server were away1.2, 8.2
F, egressdefault deny, allowed hosts, interception, pass-through, and credential injection on both dataplanesPart 6
G, the dataplanesthe install from release images, and the same inventory for Envoy and for agentgatewayPart 2, Part 6, 7.2
I, capacitya full pool, and the request parking demoPart 4
J, death in a sandboxa process that exits, and one that the kernel kills for memory3.5, 8.3
K, kagentthe documented kagent install, 98 turns, parallel turns, and what a files-only snapshot keeps7.1
L, the gapsthe microVM class on the first guest, node stops on a cluster with two worker nodes on the second guest (16 vCPU, 23 GB), and worker recovery after a node restart4.2, 4.3, 3.5, 8.2
M, Apple siliconthe install from release images and three actor sizes on a laptopPart 2, Part 3
N, the demosan idle suspender, a stateful agent, a tool sandbox, and a failure drill, on their own install on the second guest with 16 vCPU and 31 GBPart 9
P, a node that stops13 cases on the cluster with two worker nodes: reverts during and after the outage, a rescue from a tag, an async-suspended actor, a running actor, and the return of the node3.4, 3.5, 4.3, 8.2
Q, network checkseight checks on the second guest (16 vCPU, 23 GB): dependency loss on agentgateway, a reused connection after a rule removal, a model gateway behind egress over TLS, the egress log level, a suspend as a cut-off, a changed Secret, the router ports, and the CPU limit4.1, Part 6, 7.2, 8.2
R, Agent Executorthe install of its head commit on Agent Substrate v1.0.0, what each of its objects becomes, lifecycle times, and where its documents and the run disagree7.5
S, a real modela small open model on CPU, kagent turns with it, and five Agent Executor follow-ups: a model call, the example agent image, a clone that succeeds, worker loss under a task, and release v0.3.17.1, 7.5
T, a drain and a bucket outside the clusterfive drains of one worker node, a node stop with the other node full, and a bucket outside the cluster with added delay and with an outage, on the second guest (16 vCPU, 23 GB)4.3, 8.1, 8.2
U, Postgres, upgrade, and scalean install with a Postgres outside the cluster and its outage, one upgrade from v1.0.0-rc.3 to v1.0.0, and bursts of resumes on one node with up to 10,750 actor rows, on the first guest1.1, 4.5, 8.1, 8.4
X, a bucket and a Postgres on another machinebucket cycles with TLS, with a rate limit, and with added delay, two bucket outages with actors that hold data, an install with Postgres on a second guest, and two outages of that database, on the second guest (16 vCPU, 23 GB)4.4, 8.1, 8.2, 8.3
W, bounds and open commandstwo settings that bound a kagent turn, and Agent Executor again: a worker loss with a command open, the skills and MCP servers of a workspace, the example with its own prompt and repository, and second runs of the clone and of the agent image7.1, 7.5
Y, second runs, a rollback, and v0.6.0three outages of the bundled Postgres and one each of ate-api-server and the Kubernetes API server, a second upgrade from v1.0.0-rc.3 to v1.0.0 and a rollback, and an install of v0.6.0 with a direct upgrade, a reinstall, and Agent Executor v0.3.1, on the first guest4.3, 7.5, 8.2, 8.4
Z, bursts on one nodea pool grown to 300 workers in steps of 50, bursts of 150, 200, and 300 resumes, the restore time inside each worker, the CPU of each component, and 1 to 4 replicas of ate-api-server and of the router, on the first guest1.1, 4.5, 8.2, 8.3
V, validationtwelve checks on the first guest: memory that holds no data, a Python runtime, resumes at the same instant, fidelity, templates, access policies, actor JWTs, a preview feature, a process exit, and a suspend during a requestPart 3, 4.4, 4.5, Part 5, 8.5

Rank 2: the code

SourceUsed in
Agent Substrate, tag v1.0.0, commit b34fbee742906494b5eb4f1f36987176bf8770a4, released 2026-11-05, github.com/agent-substrate/substrateevery section
The same repository at tag v0.1.0, for the comparison of old and new instructions8.4
kagent, tag v1.0.0, released 2026-11-09, github.com/kagent-dev/kagent7.1, 8.1
agentgateway, tag v1.7.0, github.com/agentgateway/agentgateway6.6, 7.2

The manual reads seven parts of the Substrate code most. They are the API definition, the lifecycle workflows of the API server, the snapshot tracker of atelet, and the request parking code of the router. The others are the database migration, the install manifests, and the command-line plugin. The manual source records the files that each section used, and the audit checks that record.

Rank 3: documentation at the tag

These pages of the Agent Substrate documentation are cited by their titles. We checked each statement that we repeat against the code, and against a run where we had one.

The project site, ate.dev, serves the same pages at version 1.0. We read all 48 pages of the site on 2026-11-12 and compared the 38 that have a file at the tag with that file. We found differences in rendering only, such as link text and placeholders, and no difference in content. The first page of the site is a source of its own, because it states results with no conditions. What Substrate does, and what it leaves to you sets each of those statements beside our runs.

PageUsed in
the quickstartPart 2, 8.4
the architecture pagePart 1
the API guidePart 3, Part 4, 8.1
the resource model and the glossaryPart 3, the glossary
the request parking page4.4
the sandbox classes page4.2
the guides for authentication, authorization, and actor JWTsPart 5
the guides for network egress, the trust bundle, and credential injectionPart 6
the PostgreSQL guide and the node storage guide8.1
the upgrade runbook and the version skew policy8.4
the page on preview features8.5, the flags reference
the key rotation guide for actor JWTs8.5
the installer command reference and the operator install guidePart 2, the flags reference
the kubectl ate reference2.2, the command reference

Three statements in these pages do not match the release. The quickstart says that Substrate suspends an idle actor, and our run and the code show that it does not. The upgrade runbook gives an example version that does not exist. The help text of one installer flag says that a credential provider requires Envoy, and the provider worked with agentgateway.

Rank 4: issues

The issues are reports by users and maintainers. We name an issue where it explains a limit that we did not run, and we say so in the text.

IssueWhat it reportsUsed in
#3101node-local snapshots filled a node disk, and the kubelet evicted the workers8.1
#3775, #2972the design for detecting a dead process inside a living sandbox8.5
#3441a proposal to reserve less than the declared limit for each actor8.5
#3589ingress authorization is the same for every atespace8.5
#3203a proposal for other storage backends, which the maintainers declined before the release8.5
#3602a durable directory has no size limit8.5
#3347the reuse of a name is not safe8.5
#3285a change of egress policy does not close open connections, which our run confirmed6.6, 8.5

Other sections cite more issues, and each names them in its own text.

The project marks in the figures belong to their owners, and we use them only to identify the projects.

Index of figures

Every figure of this manual, in order, by the one claim that its caption makes.

Each row links to its figure, and the claim is the claim: field of the figure block. A measured number in a figure comes from our recorded runs, and the chart figures read the summary files directly. Two charts are exceptions: figure 1.2b and figure 6.6 read their recordings directly. The colours follow the palette of the front matter:

  • blue is the Substrate control plane: ate-api-server, ate-controller, and ate-idp-server
  • violet is workers and sandboxes: atelet, worker pods, gVisor, and microVM
  • green is the actor, the workload that you run
  • plum is the agent harness and its model calls: kagent and the model
  • olive is the client and hosts outside the cluster
  • indigo is the dataplane: atenet-router, atenet-egress, Envoy, and agentgateway
  • amber is a lifecycle state or a step between two states
  • teal is durable state: Postgres, the object store, and snapshots
  • rose is a failure: a crashed actor, a 500, a 503, or lost calls
  • grey is outside Substrate: Kubernetes, the node, and the operator
Fig.The claim it makes
0.1Each arrow style marks one kind of exchange, and every label is a real name, message, or number from our recorded runs.
1.1Substrate resumes an actor onto a worker when a request arrives, and the call that suspends it again must come from you.
1.2The first request to a suspended actor makes the router call ResumeActor, and ate-api-server restores the actor before the request reaches it.
1.3Only the first request paid for a resume, and the request after 900 idle seconds was as fast as the others because the actor never stopped.
2.1From the release images deploy ate-system took 162 s on one guest, and from source on a larger guest it failed, then took 266 s.
2.2kubectl ate reads atespaces, templates, actors, and workers from Postgres through ate-api-server, and kubectl reads only three custom resources from etcd.
2.3With kube-apiserver down for 86 s the proxy served no request, and a pod that called the router served every one.
2.4The install put the control plane, the dataplane, the node agent, Postgres, and the object store in one namespace on one node.
2.5In ate-system only ate-api-server and ate-idp-server run two pods, and every other workload, the router and Postgres among them, runs one.
3.1Substrate builds one golden snapshot for each template, and an actor created after that starts suspended from it.
3.2At 1,000 MiB the first call of a new actor took 3.5 to 12.7 seconds, and a later resume took 2.2 seconds.
3.3An actor leaves ACTOR_STATE_CRASHED only through RevertActor or DeleteActor, and no request can resume it.
3.4At 1,000 MiB a synchronous suspend took 6.4 seconds, and the default asynchronous suspend took 2.0 seconds.
3.5A resume from the node took 2.2 seconds at 1,000 MiB, and a resume from the object store took 4.7 seconds.
3.6Only SNAPSHOT_FIDELITY_MEMORY keeps the processes, and the two lower levels start new processes on every resume.
3.7An actor that held 500 MiB of zero bytes had a 4.6 MiB snapshot, and the same actor with random bytes had 504.1 MiB.
3.825 suspended actors that held 8,750 MiB of memory used 8,801 MiB on the node and 10,824 MiB in the object store.
3.9For the first five minutes after an asynchronous suspend, the only copy of the new snapshot is on one node.
3.10The text of the error tells you the state of the actor, and the state tells you the one call that helps.
3.11An atespace holds templates, actors, and tags, a namespace holds worker pools, and only a label selector joins them.
4.1A WorkerPool becomes a Deployment of worker pods, and each pod becomes a Worker record that reports the limits of the pod as capacity.
4.2An actor used 0.25, 0.50, and 1.0 host cores at limits of 250, 500, and 1000 millicores, and it needed four threads to use 2.0 cores at 2000.
4.3A gVisor actor shares the node kernel through a user-space kernel, and a microVM actor runs on its own guest kernel under KVM.
4.4At 0 MiB a microVM resume took 0.33 seconds against 0.22 for gVisor, and at 500 MiB it took 0.80 against 1.07.
4.5A worker must pass six checks before it is a candidate, and the scheduler then takes the less loaded of two random candidates.
4.6A suspended actor with a complete snapshot stayed in RESUMING for 5 min 33 s from its first request, on a stopped node, then became CRASHED.
4.7A parked request is served when a worker becomes free inside the 5 second budget, and it gets a 503 when none does.
4.8Sixteen resumes at the same instant took 0.37 seconds each at the median, against 0.23 seconds for one resume alone.
4.9Two workers with a 2 CPU limit ran four one-CPU actors, and one worker with no limits ran sixteen on a node with 16 vCPUs.
4.10One node finished 32 to 39 resumes each second from 50 to 300 at one time, so the wait grew with the size of the burst.
4.11Inside a burst about 100 restores ran at once, and each one took about ten times as long as a restore alone.
5.1The API and the router check a client separately: the shared ate-client token owns every actor, and the router serves any identity on its one list.
5.2A role on the global scope holds in every atespace, and a role on one atespace holds only there.
5.3The egress gateway mints the token and replaces a header with it, so the service receives an identity that the actor did not send.
6.1The router calls ResumeActor for every request, also when the actor already runs, and only then opens a tunnel to the worker.
6.2Both dataplanes refuse a caller with no certificate or the wrong SPIFFE ID in the TLS layer, so the caller gets no HTTP status.
6.3A connection from an actor passes two checks, the DNS relay for the name and atenet-egress for the request, and each one reads the same policy.
6.4A short Service name passes the DNS relay through the search list, and the gateway then refuses it because the Host header is not in the policy.
6.5In our polled runs a new rule reached DNS within 3 seconds, and in one unpolled run on Envoy the name stayed unresolved for 16 seconds.
6.6With an https rule the actor holds a session with atenet-egress, and with a tlsPassthrough rule it holds a session with the real host.
6.7The secret travels from the Kubernetes Secret to atenet-egress and on to the upstream, and it enters the actor only if the upstream sends it back.
6.8A changed Secret reached the upstream in 1.0 to 1.6 s on Envoy and in 100.6 to 280.5 s on agentgateway.
6.9The Envoy dataplane spreads its work over five containers and 91 MB, and agentgateway uses two containers and 38 MB with the CA key inside one.
6.10Both dataplanes refused new requests and requests on a reused connection within 9 seconds, and neither closed an open response or pass-through connection.
7.1Every turn resumes the Session actor through the router, and kagent calls SuspendActor as soon as the task completes.
7.2A turn that follows the last one with no pause costs the same as a turn 4 s later, because every turn resumes the actor.
7.3The model was about nine tenths of every clean turn, and one turn of 50 did not end.
7.4Sixteen of 24 parallel turns ended within 5.45 s, and the other eight waited for a free CPU and ended up to 2.4 s later.
7.5In places A and B agentgateway enforces the Substrate policy, and only in place C does it read model and tool traffic.
7.6The installer creates cloud resources in your project and installs the same control plane in your cluster that ate-setup installs on kind.
7.7Substrate fits only when all five answers are yes, and each no names a simpler choice that fits better.
7.8One task became one actor and one template with a files-only snapshot, and no object opened egress or chose a pool.
7.9A resume of a prepared task took about 0.24 s, and a resume whose clone failed took more than 10 s because the runner tried five times.
7.10The resume after a worker loss took 0.3 s in both runs, and it kept the saved file only when the snapshot had reached the object store.
7.11An open command ended with an error within 0.2 s of the delete, and nothing started it again.
8.1Postgres holds the record of each actor, the bucket holds its durable snapshot, and the node disk holds a second copy for 30 minutes.
8.2With 20 ms of added round trip, a resume of 1,000 MiB from the bucket took 10.2 s and not 4.8 s.
8.3With the bucket on another guest, a request to an actor of 1,000 MiB got a 504 after 10 s and its first 200 after 12 s.
8.4Requests failed without Postgres and without ate-api-server, and every request from inside the cluster succeeded without the Kubernetes API server.
8.5Without Postgres a failed request took the full 9 s of the client timeout, and without ate-api-server it took 5 s at most.
8.6A running actor answered again 3 s after Postgres was ready on Envoy and 21 s after it on agentgateway, in one run each.
8.7The Kubernetes API returned after 8 s and all pods after 147 s, and the actor needed a revert and 78 more seconds.
8.8A tag made before the stop gave a working actor in 1.2 s, and every other path waits for the workers or for the node.
8.9A drain crashed each running actor and did not disturb any suspended one.
8.10The delay and the body of a failed request separate six causes that share the status codes 500 and 503.
8.11Only the worker step ends running actors, and an actor has 30 minutes there only if its process survives SIGTERM.
8.12In both runs suspends and resumes failed in the atelet step, and a running actor that exits on SIGTERM crashed when the worker image changed.
8.13Substrate performs the lifecycle operations that you request, and your harness or your platform team must decide when to request them.
9.1The suspender forwards each request, keeps the time of the last one for each actor, and calls SuspendActor when an actor is quiet.
9.2All 140 requests returned 200, and the worker held one actor for 38 of 58 samples in place of the three that had traffic.
9.3The conversation returned under both fidelities, and the note, the file under /tmp, and the process itself returned only under memory fidelity.
9.4A turn that resumed the actor took 0.30 seconds under memory fidelity and 4.5 to 5.1 seconds under volumes fidelity.
9.5One download from the allowed mirror passed, three attempts to reach other hosts failed, and each revert returned an empty work directory.
9.6Each of the six failures gave a good answer in under one second after its fix, and only one of them needed two commands.
R.1A crashed actor returns to service only through RevertActor, and every path into RUNNING goes through ResumeActor.

How this manual was made

This page records the version, the machines, the lab method, and the capture kit behind every fact in the manual, and what we did not test.

The version

The manual pins Agent Substrate v1.0.0, released on 2026-11-05 at commit b34fbee742906494b5eb4f1f36987176bf8770a4. The integration sections pin kagent v1.0.0 and agentgateway v1.7.0. Every cluster ran Kubernetes 1.37.0 on kind v0.33.0. We recorded the runs and verified the facts on 2026-11-12.

Agent Substrate changed its installer, its API names, and its states in the weeks before 1.0.0. A statement in this manual is about v1.0.0 only. Section 8.4 lists what differs in older text.

Four kinds of evidence

Each fact carries one of four labels, and the sources page ranks them.

We measured: the number comes from one of our recorded runs. The paragraph names the machine and the number of samples. You can rerun the script and compare.

The code: the default or the mechanism comes from the source at the release tag. We read it and did not run it, unless the same paragraph says that we did.

The documentation: the statement comes from the project documentation at the tag. We use it for paths that we did not run, such as an install on GKE, and the text says so.

An issue: a user or a maintainer reported the behaviour in the public tracker. We name the issue and do not present it as our result.

Our runs rank first. Where a run and the documentation disagree, the manual reports the run and names the disagreement. The clearest case is the idle actor. The quickstart says that Substrate suspends it, and our actor read RUNNING in 60 of 60 samples over 900 s.

The four test beds

Name in the manualMachineWhat ran on it
an x86 server guest (24 vCPU, 47 GB, nested KVM)a KVM guest on a shared x86 server, Ubuntu 24.04, Docker 29.1.3, Go 1.27.3Agent Substrate built from source, the Envoy dataplane, gVisor: experiments A, B, C, D, I, J, and V. Also the microVM class and the second node restart of experiment L. Last, new kind clusters from the release images for experiments U, Y, and Z, and one built from source at v0.6.0 for Y
a second x86 server guest (16 vCPU, 23 GB)a guest on the same serverAgent Substrate from the release images, Envoy and then agentgateway v1.7.0: experiments E, F, and G. Also a second cluster with three kind nodes for experiment L. Later, at the same time, experiment P on the cluster with three nodes and experiment Q on the cluster with one node. Last, experiment T on the cluster with three nodes
a second x86 server guest (16 vCPU, 31 GB)a guest on the same serverTwo clusters, one after the other. kagent v1.0.0 with its own distribution of Substrate, and a mock model: experiment K. Then a separate install of Agent Substrate from the release images, with the Envoy dataplane, for the four demos of Part 9: experiment N. Then Agent Executor at one commit on that second cluster: experiment R. Last, a small open model on CPU in a container on the guest, for kagent and for Agent Executor on the two clusters: experiment S. After a restart of the guest, the same two clusters and the same model: experiment W
an Apple silicon laptop under Docker Desktop10 CPUs and 8 GB given to DockerAgent Substrate from the release images, gVisor only: experiment M

The four are different machines. Two of them have 16 vCPU, so every measured paragraph gives the memory size too. A number from one machine is never a controlled comparison with a number from another.

Three sections show numbers from two machines side by side. Section 2.1 has the two installs. Section 8.2 and section 9.4 have the two Postgres recoveries. Each one names both machines and says that the difference is indicative only.

The lab method

  • One guest for each track. Each line of work had its own virtual machine, so that a destructive test in one could not change a number in another. Nothing was installed on the server itself.
  • Disks in memory. Each guest kept its disk in the memory of the server. Disk writes were faster than on a cloud volume, so our suspend and upload times are lower limits for a real disk.
  • One node, with one exception. Every cluster but one had one kind node. The exception had one control plane node and two worker nodes. It gave the placement and node loss results of section 4.3 and the runbook of section 8.2.
  • Two experiments shared a guest. Experiments P and Q ran at the same time on one guest, each on its own cluster. CPU was shared, so read a difference of a few milliseconds in those runs as noise.
  • An object store on the same machine. Snapshots went to rustfs on the same node and the same disk. In experiment T the bucket ran outside the cluster, on the same guest, with an emulated delay. In experiment X it ran on a second guest of the same server. No bucket was in a cloud, and the path between the guests is not a real network link.
  • Two install paths. We installed once from source, as the quickstart documents, and once from the release images, on two different guests. The two paths gave us the buildx failure and the install times of section 2.1.
  • A mock model, and then a small real one. In experiment K a small server in the cluster answered each model call with a fixed reply. Those turn times in section 7.1 hold almost no model time. In experiment S the model was qwen2.5, an open model of 1 to 2 GB. It ran on 12 CPUs with no GPU, at 7 to 14 tokens per second. Both graphics cards of the server were in use by another team. A model on a GPU or a hosted model gives other model times.
  • Client-side times. Unless a section says otherwise, a time is what the client saw, through kubectl ate proxy or from a pod in the cluster. A warm call through the proxy took 0.058 to 0.068 s on the first guest, and that cost is inside every proxy number.
  • Small samples. Most points have 5 or 10 samples, and some events were recorded once. Each paragraph gives its count. Treat a result from one run as an observation.

The experiments

LetterQuestionMain result
ADoes the source quickstart work as written?Only after docker-buildx was installed. 266 s for the system step.
BDoes Substrate suspend an idle actor? What do suspend, resume, crash, and revert return?It does not. Each operation is in section 3.3 and section 3.5.
CHow does resume time grow with the memory that an actor holds?About 2 ms for each MiB of random bytes from the node, and about twice that from the bucket.
DWhen is a snapshot safe?When its durable copy shows COMPLETED, about 5 minutes after the suspend by default. The timer alone is not proof. Before that, losing the node copy crashes the actor on the next request.
EWhich requests fail when a dependency is away?All of them without Postgres or ate-api-server. None without the Kubernetes API server.
FWhat does an egress policy do on each dataplane?Default deny, interception, pass-through, and injection work on both, with different error texts.
GHow do the two dataplanes differ?agentgateway uses fewer containers and less memory, and it keeps the interception key in the dataplane container.
IWhat does a client see when a pool is full?A 503 after about 5 s, or a served request if a worker becomes free in that time.
JDoes Substrate notice a process that dies in a sandbox?A memory kill in about 4 s. A clean exit never, until something calls suspend.
KHow does kagent 1.0 use Substrate?One actor for each session, resumed and suspended on every turn, 98 of 98.
LHow does the microVM class compare with gVisor? What happens on two worker nodes when one stops? How do workers recover after a node restart?A microVM resume cost about 1 ms for each MiB, against about 2 ms. No actor left a stopped node by itself. After a restart, resumes failed for 10 to 30 s after the control plane returned.
MDoes v1.0.0 work on Apple silicon?Yes for gVisor actors up to 200 MiB. No KVM, so no microVM class.
NDo the patterns of Part 9 work on a new install from the release images?Four demos ran: an idle suspender, a stateful agent, a tool sandbox, and a failure drill. Sections 9.1 to 9.4 report them.
PWhat can an operator do when a worker node stops and stays down?No actor left the node by itself, in 13 cases. A revert helped in one situation, and it left the actor in REVERTING in the others. A tag from before the stop gave a working actor in 1.2 s. The runbook is in section 8.2.
QDo eight open statements about the network and the CPU limit hold in a run?The second table below has one row for each.
RDoes Agent Executor work on Substrate v1.0.0 as its pages say?One commit installed and ran tasks with no model. A task stayed RUNNING for 600 s with no request. A task cannot bind a gateway yet, so a workspace clone failed. Section 7.5 reports it.
SWhat changes with a real model, and do five open statements about Agent Executor hold in a run?With a small model on CPU, the model was about nine tenths of a kagent turn, and one turn of 50 did not end. The third table below has one row for each Agent Executor item.
TWhat do a drain, a full node, and a bucket outside the cluster do to an actor?A drain crashes a running actor and does not disturb a suspended one. The fourth table below has one row for each item.
UDo an outside Postgres, an upgrade, and a larger count of actors work as the project says?The install needed one fix, the upgrade crashed the running actor, and one node finished about 37 resumes each second. The fourth table below has the rows.
VDo twelve statements that the first draft marked as not tested hold in a run?The first table below has one row for each.
WCan a kagent turn be bounded, and do four more open statements about Agent Executor hold in a run?Two settings of the ModelConfig bound a turn. The fifth table below has one row for each item.
XWhat do a bucket and a Postgres on another machine cost, and what happens when each is away?A resume that passes 10 s returns 504 first, and a later request succeeds. One running actor stayed at 504 after a database outage until the router restarted. The fourth table below has the rows.
YDo the outages and the upgrade repeat, and do a rollback and an upgrade from v0.6.0 work?The outages and the upgrade repeated, and the rollback crashed the same actor as the upgrade. Nothing made on v0.6.0 survived. The sixth table below has the rows.
ZDo bursts above 150 resumes work on one node, and what sets the rate?Up to 300 at one time, at 32 to 39 each second. The restores inside the gVisor workers set the rate, and the control plane did not. The seventh table below has the rows.

The letters H and O are not in the table. H was a single check that a plain scratch image works as an actor, and we did not use the letter O. Experiment L closed three gaps of the earlier runs, and the sections of Part 4 report it.

Experiment V ran on the first guest, with a pool of two workers that hold 16 one-CPU actors. It took twelve sentences of the draft that said we did not test something, and it tested each one.

Item of VQuestionResultSection
1Does a snapshot shrink when the memory holds no data?Yes. 500 MiB of zero bytes gave a 4.6 MiB snapshot and a 0.23 s resume. Size and resume time follow the memory that holds data.3.4, 3.3
2Does the resume rule hold for an ordinary runtime?A Python server with a peak of 245 MiB resumed in 0.41 s. Its golden snapshot took 39.3 s to build.3.3, 3.1
3What do resumes at the same instant cost?0.37 s each at 16 at once, against 0.23 s alone, 155 of 155 served. A burst of 32 on 14 free places: 14 served, 18 refused after 5.1 s.4.4, 4.5
4What does each of the three fidelities keep?The root filesystem level keeps files and starts new processes. Both lower levels resume as a cold start.3.4
5What happens to an actor created before the golden snapshot exists?It has no snapshot, its first call is a cold start, and it never adopts the golden snapshot.3.1
6Can an actor move to another template?Only when suspended. The volumes continue, and the memory and the root filesystem start new.3.1
7Can a template use a pool in another namespace?Yes. A selector that matches no pool is accepted and looks like a full pool.3.6
8Do access policies and token files work as documented?Yes. Each role was refused where the documentation says, and a change took effect within 2 s.5.1, 5.2
9What is in an actor JWT?The documented claims, signed with ES256. The gateway used one token for several requests and across a suspend.5.3
10What does a preview feature need?The server flag and the client flag. With either one missing, the error names the field only.8.5, R.5
11Does Substrate ever find a process that exited?Not in 20 minutes. The dead actor kept its CPU booking until a suspend marked it crashed.3.5
12Does a suspend wait for an open request?No. The request got a 503 after 2.2 s, and the suspend reported success.3.3

The first run of item 3 used wrong port numbers for 1 to 8 resumes, and we discarded it. The table of section 4.4 is the second run. The bursts of 32 are from the first run. The first try of item 9 failed because the actor asked for the host name before the policy existed, and section 5.3 reports it.

Experiments P and Q ran after V, on the second guest (16 vCPU, 23 GB). Each node-stop case of P ran once. Q took eight statements that the draft had from the code only, or that it marked as not tested. It ran each one on both dataplanes where the dataplane matters.

Item of QQuestionResultSection
gDo the three dependency outages give the same result on agentgateway?Yes. Every failure body is ingress failed, and a running actor answered again 21 s after Postgres was ready, against 3 s on Envoy.8.2
hDoes a reused connection keep a removed rule alive?No. The next request got 403, about 1 s later on agentgateway and 5 to 9 s later on Envoy.6.6
iDoes a model gateway work behind an egress rule, over TLS?Yes, and the gateway selects the model. A private CA failed until we replaced the root file of the egress container. A new intercepted connection cost about 0.6 s.7.2, 6.4
jDoes a lower log level remove header values from the agentgateway egress log?Yes. RUST_LOG=info gave 100 lines for 100 requests and no header value. The installer sets debug again.6.6
kDoes a suspend cut an open connection?Yes, in 1.3 to 2.2 s, and the connection does not come back after a resume.6.6
lHow soon does a changed Secret reach the upstream?After 1.0 to 1.6 s on Envoy, and after 100.6 to 280.5 s on agentgateway. No request failed.6.5
mWhich ports does the router have on each dataplane?443 and 8444 need a client certificate on both. agentgateway also has a stats port that needs no credentials, and two Service ports with no listener.6.1
nIs the CPU limit of an actor a cap?Yes, a hard cap from a cgroup for each actor. The actor sees 2 CPUs at every limit, and CPU time read inside it is wrong under the cap.4.1

The first test of item n stopped each run when the CPU time inside the sandbox reached 10 s. That CPU time is not valid under a cap. We kept the file as the record of the mistake, and we ran the test again with a fixed amount of work. The clean-up of experiment P deleted the golden tags of two templates, and section 3.4 reports what that broke.

Experiment S ran after P and Q, on the guest with 31 GB. It took the five things that section 7.5 listed as not run, and it replaced the mock model of experiment K with a real one.

Item of SQuestionResultSection
kagentWhat does a turn cost with a real model?11 to 17 s at the median with a small model on CPU, of which resume and suspend were about 0.7 s. One turn of 50 did not end, and nothing bounded it before the client timeout at 300 s.7.1
modelCan a task call a model through a Model object?No. No field of a task names a Model. With an http rule set on the actor, 20 of 20 calls returned 200.7.5
agentDoes the example agent image run?Yes, in one run with one short prompt, without a vendor key. The task stayed Running after the agent ended.7.5
cloneCan a workspace clone?Not through Agent Executor alone. With a tlsPassthrough rule on port 443 set on the actor, the clone worked in a resume of 9.57 s. An https rule failed on the certificate.7.5
lossWhat happens when a worker goes away under a task?The actor is CRASHED in about 1 s. ax resume task repaired it in 0.3 s. A file that a suspend had saved 8.7 s before the loss was gone.7.5
releaseDoes the release v0.3.1 run on Substrate v1.0.0?No. It calls a gRPC service name that v1.0.0 does not serve.7.5

Experiments T and U ran after S, at the same time on two guests. T ran on the cluster with two worker nodes on the second guest (16 vCPU, 23 GB). U ran on the first guest, on new kind clusters from the release images. They took six things that this page listed as not tested. Each case, each outage, and each upgrade step ran once.

ItemQuestionResultSection
T, drainWhat does kubectl drain do to an actor?It crashed 2 of 2 running actors, and one revert repaired each. 3 of 3 suspended actors resumed on the other node with no command.8.2
T, full nodeDoes the runbook work when the other node has no free worker?The revert and the tag rescue were accepted, and requests got 503 after 5 s until a worker was free.8.2, 4.3
T, bucketWhat does a bucket outside the cluster cost, and what happens when it is away?With no added delay, little. With 20 ms of round trip, a resume of 1,000 MiB took 10.2 s. With the bucket away, a synchronous suspend crashed a running actor. The bucket ran on the same guest, with an emulated delay.8.1, 8.2
U, PostgresDoes an install work with a Postgres outside the cluster?Yes, on the third try. The documented database script fails unless it runs in one transaction. An outage looked the same as with the bundled database.8.1
U, upgradeWhat does a client see during an upgrade?From v1.0.0-rc.3 to v1.0.0: 24 s of failed suspends and resumes in the atelet step, and a crashed running actor in the worker step. No older release has images.8.4
U, scaleDo the scale statements of the project site hold on one node?A single resume stayed at 0.36 s up to 10,750 actor rows. One node finished 36 to 39 resumes each second. The ramp stopped at the disk of the guest.4.5, 1.1
X, bucketWhat does a bucket on another machine cost, with TLS, a rate limit, and delay?With no shaping, 200 MiB resumed in 2.64 s and 1,000 MiB in 12.15 s. In 26 of 51 cycles the first answer was a 504 after 10 s, and a later request returned 200. At 20 Mbit/s, 200 MiB took 88 s. TLS needed a patch.8.1, 4.4
X, bucket outageDoes the bucket outage look the same with data in memory?Yes. A synchronous suspend crashed the running actor and lost 2 calls, in 2 of 2 cases. A default suspend lost nothing.8.2
X, PostgresDoes an install work with Postgres on another machine, and what does an outage do?Yes. 20 ms of round trip added about 0.5 s to a suspend and to a resume. After 1 of 2 outages, one running actor returned 504 until the router restarted.8.1, 8.2

The first attempt at two cases of T sent its requests to the wrong port, and we ran both again. One pass of the bucket outage has rows with a client error, and the summary leaves those rows out. The capture kit names each file that we did not use.

Experiment X ran after a restart of the guest with 23 GB, at the same time as W. The bucket and the database ran in containers on the guest with 31 GB. The two guests reach each other through the user-mode network of the hypervisor, at about 100 MiB each second. The first attempt at the bucket cycles sent a revert after a 504, and we ran the cycles again without it.

Experiment W ran on the guest with 31 GB, after a restart of that guest. It took four things that this page and section 7.5 listed as not run. The models were the same two small models on CPU.

Item of WQuestionResultSection
boundDoes a setting of kagent bound a turn?Yes, two on the ModelConfig. With maxTokens: 128 the turn completed with a cut answer, in 3 of 3 turns. With timeout: 20 the turn failed after about 21 s and the actor was suspended, in 3 of 3. A client timeout alone left the actor RUNNING and the model busy for 10 minutes.7.1
open commandWhat happens to an open ax ssh command when the worker goes away?It ended with an error within 0.2 s, in 2 of 2 runs, and nothing sent it again. ax resume task repaired the task.7.5
skills, MCPWhat reaches the agent from the skills and MCP fields of a workspace?An empty skills directory. Two of four MCP servers, as a flag of the agent command. The agent reached an HTTP MCP server in 3 of 3 tasks, with an egress rule that we set on the actor.7.5
exampleDoes the documented example run with its own prompt and repository?Yes, after two fixes: a smaller memory limit, and a delete, because a task cannot be changed. The 3b model then wrote a report that was false.7.5
second runsDo two results of S with one run repeat?The clone did, in 5.47 s. The agent image did not write its file in the second run, so 1 of 2 runs did.7.5

In W, the first two tries at the worker loss stopped before the delete, because the task was not on the pool of the test. We ran both again. The first try at the example stopped at a failed resume that we did not explain. The capture kit names each file that we left out.

Experiment Y ran after W and X, on the first guest (24 vCPU, 47 GB), on new kind clusters. It took five things that this page listed as not tested. Each outage, upgrade, and rollback ran once.

Item of YQuestionResultSection
outagesDo the three outages repeat?Yes. In three outages of the bundled database, the last failed resume was sent 24 to 26 s after it was ready, and the failure text differed in one run. The running actor that stayed at 504 in X did not come back. Without ate-api-server the 503 came in 3 to 5 s again, and running actors served every request without the Kubernetes API server.8.2
upgradeDoes the upgrade repeat?Yes. The atelet step failed suspends and resumes for 34 s, and the actor that exits on SIGTERM crashed in the worker step. An actor in a suspend loop also crashed there, and it stayed crashed until a revert.8.4
rollbackDoes a rollback in the reverse order work?Yes, with the same crash in the worker step. Each suspended actor kept its count.8.4
v0.6.0Can a v0.6.0 cluster move to v1.0.0?No. The installer stops on the stored version of a CRD. A reinstall on the same cluster needed two fixes and kept no actor.8.4
release v0.3.1Does the release run on Substrate v0.6.0?No. It creates an actor with no template, and v0.6.0 refuses it.7.5

In Y, the resumes of the Kubernetes API outage are not valid, because running actors from the earlier runs had filled the pool. The bursts of 200 to 500 resumes in Y gave no data, because the kube-apiserver of that cluster stopped with 443 of 520 workers up.

Experiment Z ran last, on the first guest (24 vCPU, 47 GB), on a new kind cluster with a limit of 700 pods. It took the bursts that Y could not run, with the pool grown 50 workers at a time to 300.

Item of ZQuestionResultSection
poolDoes one node hold 300 workers?Yes. Each step of 50 was ready in 17 to 21 s, and the Kubernetes API answered in 105 to 108 ms at every step.4.5
burstsWhat do 150, 200, and 300 resumes at one time cost?Medians of 3.2 to 3.6 s, 4.3 to 4.6 s, and 7.3 to 7.4 s, at 32 to 39 each second. 4 of 2,800 requests got no answer in 90 s.4.5, 1.1
limitWhat sets about 37 resumes each second?Not ate-api-server, the router, Postgres, the client, plain CPU, or disk wait. About 100 restores ran at once inside the gVisor workers, each about ten times as slow as alone.4.5
replicasDo more replicas of ate-api-server help?No. Four replicas ran the bundled Postgres out of its 100 connections, failed 36 of 300 requests, and crashed 8 actors.4.5, 8.3

In Z, the first count of workers used the wrong filter and read 0, and we grew the pool again. The upload of 300 snapshots filled the guest disk fast, so we deleted 150 actors and ran 200 and 300 at one time twice each. Before the router test we deleted one actor that we wrongly thought had stayed in ACTOR_STATE_RESUMING. Its two later requests got 404, and the capture kit marks them.

The capture kit

The capture kit holds the recordings and one script. Agent Substrate needs a Kubernetes cluster, so the recordings cannot run in the repository CI. We made them on our machines and stored the output in the lab folder.

bash
cd manuals/agent-substrate-101
python3 capture/run.py
python3 capture/run.py --check

The first command reads the recordings and writes the summary files, which are the only input of the chart figures. The second command builds the summaries again in a temporary folder and compares them with the stored ones. It needs no network and no keys. It prints one skipped line for the recordings, because it does not start a cluster.

A large log is stored as an excerpt, in which each line is a complete line of the original. A list in the lab folder names each file that was left out or cut, with the reason. The credential values in the egress and network recordings are test values that we made up.

Record it again on your cluster

  1. Create a kind cluster with the cluster script from the Agent Substrate checkout at v1.0.0.
  2. Install with ate-setup, as section 2.1 shows.
  3. Run the script of one experiment from the scripts folder. Each script writes to a folder with the name of its experiment.
  4. Copy the output over the same folder in the lab folder, then run python3 capture/run.py from manuals/agent-substrate-101/.

Your seconds will differ from ours. The states, the error texts, and the order of events must not. If one of them differs on v1.0.0, we want to know, because it means that a claim of this manual depends on our machines.

What we measured, and what we read

SubjectMeasured in a runRead in the code or the documentation only
Installsource build, release images, the switch to agentgateway, the kagent install, Apple siliconGKE, the settings for large clusters
Lifecyclecreate, resume, both suspend modes, a suspend during a request, revert, delete, crash by pod loss, node restart, the loss of one worker node of two in 17 cases, a revert and a second revert during and after that loss, a rescue from a tag, a node loss with the other node full, a drain of one worker node in five cases, memory kill, process exit with a watch of 20 minutes, an actor created before its golden snapshot, a move to another templatea drain with a PodDisruptionBudget, the wakeup probe timeout, a change of the scope of a tag
Snapshotstimes and sizes for all three fidelities on gVisor and for memory fidelity on the microVM class, memory of zero, half random, and random bytes, a Python runtime, the two timersrestore across runtime versions
Capacitya full pool, request parking, 1 to 16 resumes at the same instant, a burst of 32, bursts of 10 to 300 resumes on one node with 200 and 300 workers, the restore time inside the worker during a burst, CPU of each component during a burst, 1 to 4 replicas of ate-api-server and of the router during a burst, a single resume with 1,750 to 10,750 actor rows, a pool in another namespace, placement of 20 actors on two worker nodes, 8 and 24 parallel kagent turnsautoscaling, reservation below the limit
Networkdefault deny, the DNS relay, interception, pass-through, injection, a removed rule with an open and with a reused connection, a suspend as a cut-off, a changed Secret, a private upstream CA, a model gateway behind an https rule, the log level of the agentgateway egress gateway, every port of the routerexternal egress processors, an MCP gateway
Identitythe ingress certificate check on both dataplanes, the shared identity warning, a token file, access policies with three roles, the header and the claims of an actor JWTthe signature check of an actor JWT, key rotation, federation of actor JWTs
Preview featuresthe two switches for GoldenEgressPolicy on ate-api-serverthe other two features, the flag on every component
Dependenciesthe loss of Postgres, of ate-api-server, and of the Kubernetes API server, on Envoy and on agentgateway, and a second run of each on Envoy. Also a Postgres outside the cluster and its loss, and a bucket outside the cluster with 20 and 100 ms of added round trip and its loss, all on the same guest. Also a bucket and a Postgres on a second guest of the same server, with TLS, a rate limit, added delay, and the loss of eacha cloud bucket, a managed Postgres, a real link with 100 ms of delay
Upgradetwo upgrades from v1.0.0-rc.3 to v1.0.0 on one node, a second roll of the worker pool, one rollback, and an install of v0.6.0 with a direct upgrade and a reinstallan upgrade from an older release that keeps actors, a rollback across a schema change, the supported paths

Section 8.5 holds the full list of what we did not test.

About this edition

Agent Substrate 101, edition 2026.11, by Rohit Ghumare. It is part of AI Engineering from Scratch, an open source course at aiengineeringfromscratch.com. The newest edition is always at aiengineeringfromscratch.com/manual-agent-substrate-101.html.

Agent Substrate 1.0.0 is the subject, at commit b34fbee742906494b5eb4f1f36987176bf8770a4 of 2026-11-05, checked on 2026-11-12. Every listing comes from a recorded run of the capture kit in the manual's source directory, and every rule is written in our own words from a declared source.

© 2026 Rohit Ghumare · MIT license. You can copy and share this manual. Keep this page and the copyright line with it. Report an error at github.com/rohitg00/ai-engineering-from-scratch/issues.