Skip to content

State & scenarios

Everything on this page uses one running example: a checkout mock with three transactions — POST /login remembers who is calling, POST /cart/items appends to their cart, and POST /checkout moves them to Paid and counts an order for everyone. Callers are told apart by their Authorization header.

{
"stateConfig": {
"sessionKey": { "source": "header", "name": "Authorization" },
"variables": [
{ "name": "userName", "type": "text", "scope": "caller", "initial": "guest" },
{ "name": "cartItems", "type": "list", "scope": "caller", "initial": [] },
{ "name": "cartCount", "type": "counter", "scope": "caller", "initial": 0 },
{ "name": "orderCount", "type": "counter", "scope": "everyone", "initial": 0 }
]
}
}
  • POST /login captures the request’s username into userName, scoped to that caller.
  • POST /cart/items appends the item to cartItems and counts cartCount up, both scoped to that caller.
  • POST /checkout moves the caller’s flow from HasCart to Paid and counts orderCount up for everyone.

Alice calls /login with {"username": "alice"}, then /cart/items twice, then /checkout. Bob does the same at the same time. Each of them sees only their own userName and cartItems; orderCount is one shared number both checkouts add to.

The rest of this page builds up to that example one concept at a time:

Terminal window
kubectl delete pod -n <namespace> -l maxoperf.io/virtual-service-id=vs_123
kubectl delete pod -n <namespace> -l maxoperf.io/virtual-service-id=vs_123

Do that twice in a row — deleting whichever pod comes back each time — and GET /cart for alice still returns her cart. State doesn’t live in the mock’s pod at all: it’s stored in MaxoPerf’s own database, shared by every replica of the virtual service.

Data egress. A stateful request — one whose transaction reads or writes state — sends that request’s headers, cookies, query and body (up to 1 MiB) from the mock’s pod to the MaxoPerf API, including when the mock runs on your own (self-hosted) private datacenter. The values it remembers are stored centrally, in MaxoPerf’s own database and in backups (retention per kind — see Inspect, clear, back up & restore). Raw session keys are never stored, logged or returned — only a keyed hash id and a masked display key. Agents (MCP, Loadrigo) only ever read redacted stored values through their own tools; the one exception is a live Try it, whose rendered response body is the mock’s real public response — exactly what any client calling the mock gets — returned as rendered (its diff stays redacted; see Try it). Stateless transactions send nothing to the API at all, so use them for data that must stay on-prem. This page doesn’t promise request-data minimization.

Access-log volume. Each request to a stateful transaction is one call from the mock to the MaxoPerf API, and that call writes one MaxoPerf API access-log line; a stateless transaction writes none. At a high stateful request rate this adds to the platform’s log volume, not only its database load.

Replicas. Every virtual service deploys with 2 replicas behind one endpoint, so any of them can answer any caller — they all read and write the same stored values. Each response carries x-maxoperf-vs-replica (the last 5 characters of the pod that answered), so you can see which one you hit. While the virtual service is running, the header shows Running · 2 replicas and State: durable · shared (both chips are hidden when it isn’t running); the State tab’s rail always shows Replicas: 2 (desired) with the hint any replica · same data · older outposts run 1. The two replicas are backed by a PodDisruptionBudget (minAvailable: 1) with soft anti-affinity — on a single-node cluster, that budget still blocks a drain of that node, so stop the virtual service first. Each running virtual service also spends 2 capacity slots of its private datacenter for as long as it runs: on a self-hosted datacenter with a container cap set, that’s 2 fewer slots for runner containers per running virtual service — raise the cap by 2 per virtual service you keep running, or stop idle ones. A datacenter with no room left fails the deploy with PDC_CAPACITY_EXCEEDED.

Redeploy for old runtimes. A virtual service whose runtime predates this release doesn’t know how to talk to the state store. The console shows Redeploy to enable durable state and the API reports runtimeSupportsState: false until you redeploy it; a virtual service that hasn’t fetched its config yet (just deploying) reports null and shows no banner.

{
"stateConfig": {
"sessionKey": { "source": "header", "name": "Authorization" },
"sessionIdleTtlSeconds": 1800,
"variables": [
{ "name": "userName", "type": "text", "scope": "caller", "initial": "guest" },
{ "name": "cartItems", "type": "list", "scope": "caller", "initial": [] },
{ "name": "cartCount", "type": "counter", "scope": "caller", "initial": 0 },
{ "name": "orderCount", "type": "counter", "scope": "everyone", "initial": 0 },
{ "name": "maintenance", "type": "flag", "scope": "everyone", "initial": false }
]
}
}

A caller who has never called /login still gets userName = "guest" in a template, a condition, or a counter — declaring a variable’s initial is enough, whether or not anything has written to it yet.

TypeExample initial
text"guest"
number0
flagfalse
list[]
object{}
counter0
ScopeConsole nameMeaning
callerEach callerone value per caller (alice’s cart is not bob’s)
everyoneEveryoneone shared value every caller reads and writes

When a starting value applies: any declared variable with no stored value reads as its initial — in templates, conditions and counters, for a caller who has never written it, for a variable you add later, and for an everyone-scoped value until the first write (also again after Clear all). Once something is stored, the stored value wins. A redeploy keeps everything already stored — nothing resets on deploy.

Telling callers apart. sessionKey names where the caller identity comes from (header, cookie, or query). Leave it unset (null, the default) and there are no separate callers at all: every request shares the single Everyone bucket, and session is just an alias for state.

Idle expiry. The console lets you set this in minutes (1–10 080, e.g. “2 hours”); the API field sessionIdleTtlSeconds takes the same thing in seconds (60–604 800), default 1 800 s (30 minutes) when unset. A caller idle longer than that is forgotten — within about two minutes after the TTL passes (one minute of slack plus the once-a-minute sweep that does the forgetting). Any request counts as activity, reads included, so a caller that sends at least one request per TTL window is never forgotten. Shared (Everyone) values are never expired. Once a caller is forgotten, GET …/state?caller=<its id> answers 404 VS_STATE_CALLER_NOT_FOUND (console: “This caller was forgotten — refresh”).

Declaring 200 variables or 256 KiB of starting values is the ceiling — see Limits & latency.

{
"state": {
"captures": [
{ "from": { "source": "body", "path": "$.username" }, "to": { "scope": "session", "key": "userName" }, "action": "set" }
]
}
}

Alice calls POST /login with {"username": "alice"} → her userName becomes "alice".

Sources (from) — where a captured value comes from:

SourceShapeNotes
body{ "source": "body", "path": "$.username" }JSONPath into the request body
header / query / path / cookie{ "source": "header", "name": "X-Request-Id" }a multi-value header/query takes the first value
template{ "source": "template", "template": "{{request.headers.x-user}}" }the only source that can combine values before storing them
value{ "source": "value", "value": 0 }a literal — any JSON

from is required for set, append and merge; it’s ignored for increment and delete.

Actions (action) — what happens to the target key, with the console’s own words:

ConsoleactionEffect
Replacesetoverwrites the whole value
Add to a listappendpushes onto an array (creating it if missing)
Count upincrementadds 1 (or the captured number) to a numeric value
—deleteremoves the key
—mergeshallow-merges an object into the stored object
  • default (optional) is written when the source is missing, or a transform’s result is null/"" (used verbatim — no further transforms run on it).
  • Up to 20 captures per transaction, run in order, so a later one can read what an earlier one just wrote.
  • Key rules: up to 8 dot segments, 256 characters, no control characters.
  • A failing capture never blocks the response — the request is still served, and captures[].reason in the low-level result says why (see Limits & latency for the full list).
  • A value or key containing a NUL character or a lone surrogate is never stored (reason: "value_invalid").
  • A request whose fault fires (an injected error or extra latency) stores nothing.
  • Think time runs after values are saved, so a slow response doesn’t delay when the write is visible to other callers.
{
"from": { "source": "header", "name": "Authorization" },
"transforms": [
{ "kind": "regexReplace", "pattern": "^Bearer\\s+", "replacement": "" },
{ "kind": "regexExtract", "pattern": "sess-(\\w+)" }
],
"to": { "scope": "session", "key": "sessionSuffix" },
"action": "set"
}

Authorization: Bearer sess-7f3a → strip the Bearer prefix → extract the part after sess- → stores "7f3a".

Every transform kind, in order, with its step JSON and a one-line example:

KindStep JSONInput → output
trim{ "kind": "trim" }" padded " → "padded"
lowercase{ "kind": "lowercase" }"MiXeD Case" → "mixed case"
uppercase{ "kind": "uppercase" }"MiXeD Case" → "MIXED CASE"
capitalize{ "kind": "capitalize" }"jane doe" → "Jane Doe"
prefix{ "kind": "prefix", "value": "acct-" }"42" → "acct-42"
suffix{ "kind": "suffix", "value": "-x" }"42" → "42-x"
template{ "kind": "template", "template": "Hi {{value}}!" }"Ann" → "Hi Ann!"
regexMatch{ "kind": "regexMatch", "pattern": "^\\d+$" }"12345" → true (a boolean)
regexExtract{ "kind": "regexExtract", "pattern": "(\\d+)" }"abc123def" → "123"
regexReplace{ "kind": "regexReplace", "pattern": "\\d", "replacement": "X" }"a1b2c3" → "aXbXcX"
dateFormat{ "kind": "dateFormat", "format": "YYYY-MM-DD" }"2024-06-15T12:00:00.000Z" → "2024-06-15"
numberFormat{ "kind": "numberFormat", "format": "fixed:2" }"3.14159" → "3.14"
path{ "kind": "path", "path": "a.b" }{"a":{"b":"deep"}} → "deep"
pick{ "kind": "pick", "fields": ["a"] }{"a":1,"b":2} → {"a":1}
omit{ "kind": "omit", "fields": ["a"] }{"a":1,"b":2} → {"b":2}
join{ "kind": "join", "separator": " | " }["a","b","c"] → "a | b | c"
default{ "kind": "default", "value": "fallback" }"" → "fallback"
required{ "kind": "required" }"present" → "present"; empty input → transform_failed
javascript{ "kind": "javascript", "inline": "return String(value).toUpperCase();" }"ab" → "AB"
  • Up to 16 steps per capture, applied in order.
  • Objects are JSON-stringified before a text-only step and parsed back after path, pick or omit.
  • A regexMatch / regexExtract / regexReplace step whose input is over 64 KiB fails (reason: "transform_failed", regex_input_too_large).
  • Any step failure sets reason: "transform_failed" — the default is not applied, and the request is still served. See JavaScript snippets for the javascript kind’s own rules.
// A snippet's body — no wrapper, just return the new value.
return String(value).replace(/^Bearer\s+/i, '').toLowerCase();
// State-aware: read a stored value alongside the input.
return state.session.userName ?? value;

A snippet is one more transform step:

{ "kind": "javascript", "inline": "return String(value).toUpperCase();" }

or, for a snippet saved in your workspace’s shared library and pinned to a specific revision:

{ "kind": "javascript", "snippetId": "dts-xxxxxxxxxxxx", "snippetRevisionId": "dtr-xxxxxxxxxxxx" }
  • Signature: (value, request, state) — the function body only; return the new value. request and state are read-only (also reachable as row.request / row.state); the body runs inside its own block, so it may declare its own local state or request variable without a syntax error.
  • Scalar in, scalar out, same as a dataset transform — objects arrive as JSON text. To store an object, return JSON.stringify(obj) and follow with a path step to parse it back.
  • A snippet only reads state when its source text mentions state or row.state (a bare row.request doesn’t count). A state-reading snippet sees the caller’s whole bucket and the whole Everyone bucket, so its cost scales with their size — keep state-reading snippets for state that stays small. Over 256 KiB combined, the step fails (javascript_state_too_large).
  • Request body cap: JavaScript sees at most a 256 KiB request body. A step that reads body on a bigger request fails (javascript_request_too_large); every other JS step on that request still runs, just without body on request.
  • helpers.random(), helpers.uuid() and helpers.nowIso() are deterministic, seed-derived values — nowIso() is not the current time.
  • Limits: 50 ms execution, 16 MiB memory, inline source ≤ 8 KiB, output ≤ 64 KiB.
  • A saved snippet lives in the workspace’s shared library (the same one datasets use — see Test data transforms) and is pinned by revision; a snippet from another workspace fails validation (400 VS_STATE_TRANSFORM_INVALID).
  • A timeout or a thrown error → transform_failed.
  • Preview before you save: in the Behavior tab, each step’s value — JavaScript included — previews from your unsaved chain. Nothing reaches live traffic until you save; a saved edit reaches the running mock within about 2 seconds.
{
"bodyTemplate": "{\"hello\": \"{{session.userName}}\", \"items\": {{toJson session.cartItems}}}"
}

Alice’s response body renders as:

{ "hello": "alice", "items": ["sku-1"] }
RootReads
statethe Everyone bucket
sessionthe calling caller’s bucket (an alias for state with no session key configured)
localvalues this exchange captured into a local-only scratch scope
scenariothe caller’s current flow step, by flow name
request.*the incoming request (headers, query, path params, body)
SyntaxEscapingUse for
{{session.userName}}HTML-escapeda plain scalar
{{{session.userName}}}Raw, unescapeda header value, or anywhere entity-escaping would corrupt it
{{toJson session.cartItems}}JSON-escapedan object or array in a JSON body

{{toJson value}} and {{values bucket}} help build JSON from a whole bucket or object; {{uuid}} generates a fresh id inline (deterministic, seed-derived, like the JavaScript helpers).

Scaling note. Name the values you actually use, like session.userName above. A bare root such as {{toJson state}}, or a lookup with a dynamic key, loads the whole bucket on every request, so its latency grows with how much that bucket holds.

Preview as. In the Behavior tab, the Response panel’s Preview renders your template for either New caller (starting values) (the default) or a live caller you pick by key mask (e.g. …7f3a · alice) — nothing is saved or sent to live traffic. When some of the previewed values are hidden, it says so: 2 hidden values preview as [REDACTED].

{
"thenOps": [
{ "counter": "cartCount", "scope": "caller", "op": "increase" },
{ "counter": "orderCount", "scope": "everyone", "op": "increase", "by": 1 }
]
}

Alice’s cartCount goes from 0 to 1; the shared orderCount goes from 12 to 13.

Each operation names its own scope (caller or everyone — console ”+ caller” / ”+ everyone”); you don’t need to declare a counter-typed variable at all, though doing so gives it a console display and a default starting value.

opEffect
increaseadds by (default 1)
decreasesubtracts by (default 1)
resetsets it to op.initial, else the declared variable’s initial, else 0
  • Counter ops run on every winning response of the transaction — every case and the Otherwise. Put a response that shouldn’t count in its own transaction instead.
  • Up to 10 counter ops per transaction.
  • An invalid op, scope or by → 400 VS_COUNTER_OP_INVALID.
  • The increment capture action (in Remember values) still works too — use whichever fits: a capture when the count is tied to one field, a then-op when it should fire on every answer.
{
"cases": [
{ "when": [{ "scope": "session", "key": "cartCount", "match": "gt", "value": 0 }], "response": { "status": 200, "bodyTemplate": "{\"order\":\"ord-{{state.orderCount}}\"}" } },
{ "when": [{ "source": "header", "name": "x-plan", "match": "equals", "value": "pro" }], "response": { "status": 202 } }
],
"response": { "status": 409, "bodyTemplate": "{\"error\":\"cart is empty\"}" }
}

The first case whose when holds wins; if none does, the transaction’s own response (the Otherwise) answers instead.

  • Up to 10 cases, each with 1–10 conditions in when (all must hold).
  • A condition reads either state/session (via scope + key) or the request (body / header / query / path / cookie).
  • Response sequences (below) apply to the Otherwise only.
  • Cases only exist on mock transactions — a passthrough transaction has nothing to branch between.
  • An invalid cases shape → 400 VS_CASES_INVALID.
{ "scope": "session", "key": "userName", "match": "present" }

A whole transaction can require a condition to hold before it’s even considered a match — not just to pick a case.

matchMeaning
equals / notEqualsexact value comparison
containssubstring or array membership
present / absentthe key does or doesn’t exist
gt / ltnumeric comparison

A failing condition doesn’t error — the candidate is skipped and the next one is tried. Only the first 50 transactions whose request matcher fits are ever considered, and that’s never an error either — it just means a very low-priority transaction can be shadowed on a busy mock.

A flow models a multi-step scenario per caller (or globally, with no session key configured):

  • login — no requiredState (any step) → moves the caller to LoggedIn.
  • cart — requiredState: LoggedIn → moves to HasCart.
  • checkout — requiredState: HasCart → moves to Paid.
  • logout — any step → moves back to Started.

The console calls a scenario a flow and a scenario state a step; Started is every caller’s initial step. A flow advance is atomic under concurrency — two simultaneous requests from the same caller advance it exactly once, even under load-testing traffic. The Flow & try it tab draws this as a canvas.

{
"responses": [
{ "status": 500 },
{ "status": 500 },
{ "status": 200, "bodyTemplate": "{\"ok\":true}" }
],
"afterLast": "repeat_last"
}

The first call from a caller gets 500, the second 500 again, the third 200 — and every call after that keeps getting 200 (repeat_last). Set afterLast: "cycle" instead to start back over from the first response.

  • 1–20 responses per sequence, tracked per caller (or globally, with no session key configured).
  • A sequence can’t be attached to a passthrough transaction — there’s no mocked response to cycle through.
POST /v1/virtual-services/{id}/crud-resources
{ "path": "/orders", "scope": "global", "seed": [ { "id": "o-1", "total": 20 } ] }
{ "groupId": "vs_service_…", "transactionIds": [ "…7 ids…" ], "seedAppliedOnReset": true }

GET /orders/o-1 answers the seeded item right away — no reset needed.

  • path names the collection endpoint. It must start with /, and its last segment (letters, digits, _ and -, starting with a letter or _) becomes the collection’s key in state (orders). A reserved word (__proto__, constructor, prototype, true, false, null, …) → 400 VS_CRUD_PATH_INVALID.
  • scope is session (each caller gets their own collection) or global (everyone shares one).
  • seed (optional, global scope only — otherwise 400 VS_CRUD_SEED_SCOPE) pre-populates the collection.

This creates seven transactions, appended as a new group named Resource: /orders (<virtual service name>). With scope: "session", capture/condition scope is session and the template root is session; with scope: "global", they’re global and state. A lower priority number is tried first, so row 3 answers 404 for an unknown id before rows 4–7 are considered. Every response is content-type: application/json.

#MethodURLPriorityStateResponse
1POSTequals /orders10capture local.id ← template {{uuid}}; set session orders.{{local.id}} ← body $; set session orders.{{local.id}}.id ← template {{local.id}}201 {{toJson (lookup session.orders local.id)}}
2GETequals /orders10—200 {{toJson (values session.orders)}}
3*template /orders/${id}5condition { scope: session, key: "orders.{{request.path.id}}", match: "absent" }404 {"error":"not_found"}
4GETtemplate /orders/${id}10—200 {{toJson (lookup session.orders request.path.id)}}
5PUTtemplate /orders/${id}10set body → orders.{{request.path.id}}; set orders.{{request.path.id}}.id ← {{request.path.id}}200 the stored item
6PATCHtemplate /orders/${id}10merge body → orders.{{request.path.id}}200 the stored item
7DELETEtemplate /orders/${id}10delete orders.{{request.path.id}}204

They’re ordinary transactions afterward — edit any of the seven the same way you’d edit a hand-written one.

Seed, exactly. A global-scoped seed[] becomes the starting value of an Everyone object variable named after the collection key: initial = { "o-1": { "id": "o-1", "total": 20 }, … } (an existing id is replaced, other keys are kept). Because it’s a starting value, it’s served immediately on a collection nothing has written yet — and again after Clear all, with no separate reset step. If a variable of the same name already exists with a different type or scope, the call fails with 400 VS_STATE_VARIABLE_INVALID and why: "crud_collection_variable_conflict" (console: “A variable named <name> already exists with a different type or scope — rename the collection or change that variable”). The seed counts toward the 256 KiB variables total (see Limits & latency).

Size. The whole collection is one stored value, capped at 1 MiB, rewritten on every create, update or delete. A POST that would push it over 1 MiB is still answered (its normal status), but carries x-maxoperf-vs- state: capacity and a null item — nothing is stored. For a large or write-heavy collection, use scope: "caller" so each caller has their own collection, or keep the collection itself small.

Generating a second CRUD resource whose collection key is already used by a Resource: … group on the same virtual service is rejected with 409 VS_CRUD_EXISTS — give it a different last path segment, or generate it on another virtual service.

On the Flow & try it tab, pick a caller (or leave scratch mode on), send POST /login as alice, and watch the timeline: + userName = "alice" and flow: Started → LoggedIn.

Try it comes in two flavors:

  • Scratch (the console default): starts from starting values and never touches live state. Under the hood this is POST …/sandbox, carrying the previous call’s stateAfter back in as the next state — nothing is committed anywhere.
  • Use live state: reads and writes the real stored state through POST …/try. Works on a draft, stopped, or running virtual service; needs the editor role.

caller (1–256 characters) is the session-key value — it’s written into the configured session-key header, cookie or query, replacing any value already there. A virtual service with no transactions yet isn’t an error: both routes answer normally with matched: false.

Response shape. Both routes return the same base shape: matched, matchedTransactionId, response {status, headers: [{name, value}], body}, notMatchedReason, captures, sequenceIndex, caseIndex, diff[] (scope: "caller" | "everyone"), scenario {name, from, to}. The sandbox additionally returns stateAfter (round-trip it as the next call’s state); /try has no stateAfter, and adds callerId instead.

The live rendered body is not redacted. diff values, the inspector, and every value route stay redacted — but response.body from a live Try it (and MCP try_virtual_service with live: true) is exactly what the mock serves that caller publicly, unredacted. Stored values only reach it through your own response templates, so keep secret-named values out of a template you intend to render — reference {{session.userName}}, not a token.

Passthrough. A passthrough transaction is never actually forwarded in Try it: it answers 204 with header x-maxoperf-vs-try: passthrough-not-forwarded, and the timeline shows passthrough — not forwarded in Try it. Its captures, counters and flow moves still apply, exactly as they would on a real forwarded call.

Try it and the Behavior tab’s JavaScript preview share the same per-pod JavaScript slots as live traffic (see Limits & latency); a store outage answers 503 (console: “State store unavailable · try again”). The state a scratch Try it or a Behavior “Preview as” sends is capped at 2 MB — over it, 400 VS_STATE_LIMIT (console: “This caller’s state is too large to preview (max 2 MB)” — preview as a new caller instead).

The State tab shows what’s stored right now: a variables table, a live callers panel, and a Backups dialog.

Clearing state never changes the virtual service’s own configuration. Only POST …/state/clear exists — Round 1’s reset route and its version-counter field are both gone from this API.

Reading state. The inspector returns up to 500 names per bucket (namesTruncated: true beyond that); a value over 8 KiB comes back as { "truncated": true, "bytes": 20480 } — open “Show full value” in the console, or call GET …/state/values/{name}?caller= directly (full value, up to 1 MiB). For a shared (Everyone) variable, “Value now” is the shared value even when a caller is selected; for an Each-caller variable, it’s that caller’s own value. A forgotten caller answers 404 VS_STATE_CALLER_NOT_FOUND (“This caller was forgotten — refresh”); calling GET …/state/values/{name}?caller= for a name with no stored entry yet — even a declared variable still on its initial — answers 404 VS_STATE_VALUE_NOT_FOUND.

Finding a caller. Caller ids are opaque, server-computed ids (a keyed hash) — never a raw session key — shown by a key mask like …7f3a. Find one with GET …/state/callers?q=7f3a (1–8 characters, matching the mask suffix), or from a /try response’s callerId. ~everyone addresses the shared bucket, ~anonymous addresses callers who didn’t send the session key at all.

With no session key configured, there are no callers: an Each-caller variable is read and written with ?caller=~everyone, and any other caller id → 400 VS_STATE_SCOPE_MISMATCH. With a session key configured, an Each-caller variable needs a real caller id or ~anonymous, and an Everyone variable needs ~everyone — a mismatch gets the same 400.

Editing a value. PUT …/state/values/{name}?caller= replaces the whole top-level value — you can’t patch one field of it through this route. Because the redacted view shows [REDACTED] in place of the real value, writing back a value that still contains the literal string "[REDACTED]" under a secret-named key is rejected with 400 VS_STATE_VALUE_REDACTED (“This value is hidden — type the whole value to replace it”) — type the whole real value to replace it, don’t copy the masked one back.

Clear variants:

ActionRouteKeeps
Clear one valueDELETE …/state/values/{name}?caller= (required)everything else; the value reads its initial again
Forget a callerDELETE …/state/callers/{callerId}every other caller and the shared values
Reset flow positions (one caller)POST …/state/reset-flows {callerId}that caller’s other values and sequence counters
Reset flow positions (everyone)POST …/state/reset-flows (no callerId)every value and sequence counter — only flow steps go back to Started
Clear all statePOST …/state/clearnothing — every bucket is deleted; starting values apply again

Every backup is one point-in-time snapshot of everything stored. Retention is per kind:

KindWhen it’s madeKept
autohourly, only when state changed7 days; at most 168 per virtual service; shares a 64 MiB cap with pre_restore (oldest pruned first)
pre_restoreautomatically, right before every restore7 days; inside the 64 MiB cap, the newest 3 are never pruned for size — they still expire at 7 days
manual (Back up now)you click it, optional 0–80 character labeluntil you delete it
uploadedyou upload a fileuntil you delete it

Manual and uploaded backups share 20 slots; a 21st manual/upload attempt → 409 VS_BACKUP_LIMIT (“20 manual backups max — delete one to free a slot”). DELETE …/state/backups/{id} frees a slot for manual and uploaded backups; deleting an auto or pre_restore backup → 409 VS_BACKUP_NOT_DELETABLE (“Automatic backups expire on their own”).

One size measure, everywhere. Every backup — automatic, manual, or uploaded — is measured the same way: the sum of its stored values must be ≤ 17 MiB (16 MiB plus 1 MiB of maintenance-tick slack) and ≤ 50 000 callers. Back up now, or restore, over that measure → 409 VS_STATE_CAPACITY; an uploaded file over it → 400 VS_BACKUP_INVALID {why: "too_large"}. The upload request itself may be up to 32 MiB (JSON overhead) — the console rejects a bigger file client-side before it ever sends it. There’s no separate “uploads ≤ 16 MiB” rule.

Uploading another virtual service’s backup. Its shared (~everyone) and ~anonymous values are kept, but every caller bucket is dropped — caller ids are computed per virtual service, so a foreign caller id can never match here. The response carries details.droppedCallers, and the console shows Uploaded · N callers from another virtual service were dropped (shared values kept).

Restore. POST …/state/backups/{id}/restore takes a fresh pre_restore backup of the current state first, then atomically replaces everything with the target backup. Every restored caller counts as seen right now — it’s not immediately forgotten by the next idle sweep. An upload, or a restore, over the 17 MiB / 50 000-caller measure fails before anything changes (409 VS_STATE_CAPACITY).

Download vs. export. GET …/state/backups/{id}/download (editor only) gives you the raw backup file, real values included — it’s for taking data out of the platform, not for agents. GET …/state/export returns the current live state in the same file format without spending a backup slot; it’s redacted by default (?redacted=true, matching secret-named values masked), and only an explicit ?redacted=false — which only the console’s download button sends — returns raw values. Agents always get the redacted form; an agent calling the generic API tool can never reach either raw path. The backup file format is maxoperf-vs-state/1.

Uploading, creating, or expiring a backup never blocks a live request — only clear all, restore, and reset flow positions for all callers briefly do (see Limits & latency).

Redaction, in one place. A value under a secret-looking key name — any key at any depth whose name ends in token, secret, password, passwd, apikey / api_key / api-key, authorization, cookie, or sessionid / session_id / session-id (case-insensitive) — shows as [REDACTED] for every role: viewer or editor, in the inspector, GET …/state/values/{name}, caller labels, both Try it/preview diffs, and the Behavior tab’s response preview (which says N hidden values preview as [REDACTED]). The only raw copies of stored state anywhere are the editor-only backup download and the raw export (?redacted=false); the one thing that is not a stored-value view at all — the live Try it’s rendered response body — is documented separately above, because it’s the mock’s public output, not a peek at storage.

Errors, verbatim. A stateful request that can’t be evaluated gets one of two different 503 shapes, and it matters which:

HTTP/1.1 503 Service Unavailable
content-type: application/json; charset=utf-8
x-maxoperf-vs-state: unavailable
x-maxoperf-vs-error: state_unavailable
{ "error": "state_unavailable" }

This is what a client calling the mock sees.

x-maxoperf-vs-state: capacity marks a served response where a write didn’t take because a value was over 1 MiB, or the virtual service is full and the write would have grown state.

LimitValue
Per stored value1 MiB (hard; measured as UTF-8 bytes of the value as JSON)
Value nesting depth128 levels (400 VS_STATE_LIMIT)
Total state / callers per virtual service16 MiB / 50 000 callers (checked about once a minute — see “Full”, below)
Declared variables≤ 200 entries, ≤ 256 KiB total (400 VS_STATE_VARIABLE_INVALID {why: "variables_too_large"}, also from a CRUD seed[] merge)
Captures per transaction≤ 20
Transform steps per capture≤ 16
Regex step input≤ 64 KiB (regex_input_too_large)
Cases≤ 10, each with 1–10 conditions
Counter ops (then-ops)≤ 10 per transaction
Candidate transactions consideredfirst 50
Key segments / length≤ 8 segments / 256 characters
JavaScript50 ms, 16 MiB memory, ≤ 8 KiB inline source, ≤ 64 KiB output, ≤ 256 KiB combined state read, ≤ 256 KiB request body
Stateful request body≤ 1 MiB
Scratch / preview state (Try it, Behavior preview)2 MB (400 VS_STATE_LIMIT)
Inspector500 names per bucket, values over 8 KiB shown truncated
Backup size (any kind)≤ 17 MiB of values, ≤ 50 000 callers
Manual/uploaded backup slots20
Automatic + pre-restore backups≤ 64 MiB per virtual service (newest 3 pre-restore exempt from the size cap)

A top-level key — a CRUD collection, a cart, any object you capture into — is one stored value, sharing that 1 MiB cap and rewritten whole on every write.

“Full.” Roughly once a minute, a sweep checks each virtual service’s total bytes and caller count. Over either cap, the virtual service is marked full (State tab: “Full — new values are refused”): until it’s back under, any write that would grow state, and any new caller, gets capacity; reads, deletes and shrinking writes still work. So a fast burst can overshoot by at most about one minute of writes, and rotating session keys can’t grow it further once it’s full. The least-recently-seen callers over 50 000 are evicted by the same sweep. These are the only two total caps — there’s no other per-request total check, and the sweep runs whether or not idle auto-shutdown is on.

Storage bound, per virtual service: 16 MiB of live state (plus about one minute of in-flight writes) + 64 MiB of automatic and pre-restore backups (plus up to 3 protected pre-restore backups) + 20 manual/uploaded backups (≤ 17 MiB each). These bounds apply to each virtual service on its own.

Latency. A stateful request costs +30–120 ms over a stateless one (the one round trip to the MaxoPerf API); a stateless request is unaffected. A stateful request whose state step doesn’t finish within 3 seconds answers 503 — a 503 never means the change was saved, except in the rare case where the network drops the reply after it committed; it is never stale data. Other 503 causes: the state store is unreachable, the virtual service isn’t deploying or running, the restore window (clear-all / restore / reset-flows-for-all only — everything else never blocks a request), or waiting for a shared JavaScript slot. Editing a live virtual service never answers 503 because of the edit itself; a saved edit applies within about 2 seconds, and for that brief window a pod may still answer with its previous copy, so a brand-new case or sequence step it hasn’t seen yet falls back to the Otherwise response.

No throttling, on any plan — a stateful request costs only the values it touches. But stateful requests share MaxoPerf’s own database with the rest of the platform, so a very high stateful request rate adds database load and slows stateful responses first. A bare-root template ({{toJson state}}), a dynamic lookup, and a state-reading JavaScript snippet all scale with bucket size rather than with the values they actually use (see Using values in responses and JavaScript snippets). JavaScript steps run on a small worker pool shared by every tenant on an API pod — a JavaScript-heavy mock belonging to another tenant can add latency or 503s to your own JavaScript-using stateful requests; requests without JavaScript are isolated from that queue. Try it and the Behavior JavaScript preview share the same slots. Everyone-scope writes serialize per virtual service — keep per-user counters on Each caller scope rather than Everyone.

“State store unavailable.” A request that got the 503 above shows in Analytics with the transaction name “State store unavailable”, and the State tab shows a banner: “State store unreachable from VS: N requests failed.”

“Ask Loadrigo: remember the username on POST /login per caller and show it on GET /me”

Everything on this page is also reachable as an MCP tool, so an agent — or Loadrigo — can build and iterate on a stateful mock the same way you would in the console.

The existing virtual-service tools still apply: update_virtual_service, list_vs_transactions, get_vs_transaction, create_vs_transaction, update_vs_transaction, delete_vs_transaction, sandbox_vs_transaction, get_virtual_service_state, create_vs_crud_resource. This release adds exactly 14 new tools, with no alias for anything removed:

ToolDoes
list_vs_state_callerssearch callers by key-mask suffix
get_vs_state_valueread one value (redacted, up to 1 MiB)
set_vs_state_valuewrite one value
delete_vs_state_valueclear one value back to its starting value
forget_vs_state_callerremove one caller
reset_vs_state_flowsreset flow positions (one caller, or every caller)
clear_vs_stateclear everything
list_vs_state_backupslist backups
create_vs_state_backupback up now
restore_vs_state_backuprestore a backup
delete_vs_state_backupdelete a manual or uploaded backup
export_vs_stateexport current state (always redacted for agents)
try_virtual_servicerun Try it — live: true returns the mock’s real rendered response
list_transform_snippetslist the workspace’s saved JavaScript snippets

Agents only ever read redacted stored values — export_vs_state always masks secret-named keys, and the backup download/upload flow stays in the console. try_virtual_service with live: true is the one exception: it returns the mock’s rendered response body exactly as served (its diff stays redacted), the same rule as the console’s live Try it.

See the MCP tools reference for exact tool arguments, and Loadrigo for how the agent chat surface uses them.