Limits and latency
Every number on this page is a default you can change, a bound you cannot, or a measurement. Where it is a measurement that has not been taken on release hardware yet, it says so rather than giving you a figure to plan against.
The four tiers and their budgets
The checks run in tiers, in order, and stop as soon as one of them decides. The expensive tier is only reached by text the cheap ones did not already settle.
| Tier | Checks in it | Default budget | What the budget is spent on |
|---|---|---|---|
structure | structure | 50 ms | Length, invisible and look-alike characters, encoded content. Local string work. |
pattern | denylist, allowlist, secrets, pii | 400 ms | A few hundred expressions plus personal-data detection over up to 64 KiB of text. |
classifier | injection, toxicity | 500 ms | Two small text classifiers, one pass each. |
semantic | policy | 1500 ms | A dedicated safety model reads the text and judges it across ten categories. The only tier that costs tokens. |
The budget is per check within that tier, not for the tier as a whole, and it is what a check has before it is treated as failed. Set any of them:
platformctl agents security set research-buddy \
--budget-ms structure=25 --budget-ms pattern=250 --budget-ms semantic=800
Bounds: 1 to 60000 ms. One millisecond is not useful but it is not wrong either; sixty seconds is the point past which your own client timeout has already fired and the budget is a fiction.
What a turn actually costs
The observed added latency per turn - median and tail, per tier, on release hardware - is measured at release and published with the release notes. The numbers above are the time budgets a check is allowed, not the time it takes, and quoting a budget as a latency figure would be misleading in both directions: the fast tiers finish in a small fraction of theirs, and the semantic tier's real cost depends on text length and on how busy the safety model is.
What you can rely on today, because it is structural rather than measured:
-
An agent on the
offpreset adds exactly zero. No call is made. -
The first three tiers are local text work on a CPU. No accelerator, no model server, no network hop beyond the one call.
-
Only the
semantictier involves a model, andtier_reachedon every verdict tells you whether it ran. A verdict whosetier_reachedisclassifierdid not pay for it. -
Every verdict carries its own
latency_ms, and each scanner carries its own. Your own feed is the authoritative measurement for your own traffic:platformctl agents security verdicts research-buddy --limit 200 -o json
Two policy choices dominate the cost, and both are opt-in:
| Setting | Effect on latency |
|---|---|
input.semantic.enabled | On in standard. This is the model call; it is the difference between tens of milliseconds and hundreds. |
output.semantic.enabled | Off in standard, on in strict. A second model call, on the reply. The single most expensive thing a policy can ask for. |
tools.enabled | Off in every preset. Adds a round trip per tool call, doubled when both arguments and results are checked. |
If latency matters more than the semantic judgement, turn the judgement off and keep everything else:
platformctl agents security set research-buddy --semantic off
That leaves credentials, personal data, injection and abuse checks running at pattern-and-classifier speed.
Text size
| Limit | Value | Enforced by |
|---|---|---|
text in one check | 65536 bytes | The platform. 413 with code text_too_large. |
input.structure.max_chars | default 65536, settable 1–1048576 | Your policy. Your own decision and your own refusal message. |
The platform cap is in bytes, not characters, and the policy setting is in characters. A message of 65536 emoji is 262144 bytes and over the platform's line regardless of what max_chars says - which is why the byte cap exists: the byte budget is what protects the service.
Raising max_chars above 65536 therefore does not raise the platform cap. It is useful in the other direction: set it lower to refuse long text under your own policy, with your own refusal message, rather than letting a 413 surface to whatever is calling you.
For documents larger than the cap, split and check each part, keeping the same session_id and passing the mapping_ref forward through the parts so placeholder ordinals stay consistent across the whole document. See the standalone API.
history is read by the semantic tier and does not count toward the text cap. Keep it to the turns that matter - it is text a model reads, so it costs tokens and time.
Personal data caps
| Limit | Value | What happens at it |
|---|---|---|
| Distinct values per session | 200 | New values are masked to <PII_{TAG}_OTHER>, which is not restorable. The verdict's reason says so. |
| Mapping reference lifetime | one hour by default, set by your platform operator | A reference past its expiry is refused with 403 and code mapping_invalid. |
Entity types in entities | no fixed cap; nine are detected by default | A type the detector does not know is simply never found. |
The 200 cap exists because the mapping reference travels in every request and response of the conversation. An unbounded one grows until the conversation stops working, so it is bounded where a real conversation will not reach it. Two hundred distinct people, addresses and numbers in one conversation is a batch job wearing a chat interface; if that is your workload, use one session per document rather than one session per run.
The reference's lifetime is sized for a conversation, not for a week. It is deliberately much shorter than any credential's.
Retention
| Limit | Value |
|---|---|
logging.retention_days | default 30, settable 1–365 |
| Audit entries | permanent, append-only - not governed by retention_days |
| Agent logs | 14 days, the platform default for every workload |
platformctl agents security set research-buddy --retention-days 90
platformctl guard policy set --retention-days 90 # the project-wide default
A verdict expires. The audit record that a refusal happened does not. See verdicts and audit.
Rate limits
| Limit | Default |
|---|---|
| Checks per credential, sustained | 200 per second |
| Burst | 400 |
Counted per credential and per serving instance, so the effective ceiling a caller sees is higher than the figure above. It is high on purpose: the point is to have the knob before it is needed, not to throttle ordinary use. Crossing it is 429 with code rate_limited and a Retry-After header - always an error code, never a silent allow. Your platform operator can change both numbers.
Other bounds
| Field | Bound |
|---|---|
refusal.message | 1024 characters |
Any threshold | 0 to 1 |
budget_ms.<tier> | 1 to 60000 ms |
logging.retention_days | 1 to 365 |
input.structure.max_chars | 1 to 1048576 |
| Verdicts per feed page | 1 to 1000 (--limit) |
| Safety categories | 10, fixed: violent, sexual, self_harm, illegal, hate, privacy, misinformation, copyright, jailbreak, unethical |
| Checks | 8, fixed: structure, denylist, allowlist, secrets, pii, injection, toxicity, policy |
| Decisions | 4, fixed: allow, mask, flag, block |
| Error codes | 8, fixed - see the standalone API |
The fixed sets are a frozen contract. Adding a value is a change we announce; renaming one is a breaking change we will not make quietly. You can branch on them.
When a tier is unavailable
This is the important half of the page, because it is the behaviour you get on your worst day.
A check can fail for reasons that have nothing to do with the text: it runs out of its time budget, a dependency it needs is unreachable, something raises. None of those produce an HTTP error. They produce a verdict, and which verdict is your choice:
fail.<tier> | The verdict | The end user sees | The record |
|---|---|---|---|
open | decision: flag, original text carried through | their reply, normally | a verdict whose scanner entry has error populated, reason A security check could not complete, so the request was recorded and allowed. |
closed | decision: block, text: null | the refusal message | the same, reason A security check could not complete, so the request was not allowed. |
Defaults by preset:
| Tier | standard | strict | Why |
|---|---|---|---|
structure | closed | closed | Local string work. It essentially cannot fail except under real trouble, so refusing is right. |
pattern | closed | closed | Also local. This is the tier that catches credentials; letting text past unchecked is the wrong trade. |
classifier | open | closed | More moving parts. Refusing every request because a classifier is restarting is an outage you inflicted on yourself. |
semantic | open | closed | Depends on a model server. Same reasoning, more so. |
Set them individually:
# Refuse rather than let anything through unchecked, on every tier.
platformctl agents security set research-buddy \
--fail-pattern closed --fail-classifier closed --fail-semantic closed
# Or the same thing in one word.
platformctl agents security set research-buddy --preset strict
What each kind of unavailability looks like
The safety model is unreachable. The semantic check obeys fail.semantic. Under standard that is open: requests continue, each one recorded as a flag with the error on the scanner entry, and the cheap tiers are all still running. Under strict it is closed and requests are refused with the refusal message. Either way model is absent from the verdict, because the tier did not run.
The whole guardrail service is unreachable. An agent is not left choosing between unguarded and broken: the harness applies the policy's fail behaviour for the tier it could not reach, so a standard agent flags and continues and a strict agent refuses. A caller of the standalone API gets 503 with code guard_unavailable and decides for itself - which is why your own policy's fail settings are the thing to get right rather than your retry loop.
A tier is slow rather than down. The budget fires and it is treated as a failure, by the same rules. Raising budget_ms for that tier trades latency for fewer of these; lowering it does the reverse.
Your policy could not be read. A running agent whose policy document did not arrive guards with conservative local defaults and says so at startup, rather than serving unguarded. That failure is visible in the agent's log and fixed by rolling a new version:
platformctl logs research-buddy | grep guardrails
Whatever the failure, the property that matters to a caller is the same: a failed check is a verdict with a decision in it. The branch you already wrote handles it, and there is no second error path to design.
Next steps
- Policies -
fail,budget_msand every other field. - The standalone API - size limits, rate limits and the error codes for a direct caller.
- Verdicts and audit - measuring your own latency from your own feed.
- Platform limits - quotas and bounds across the rest of the platform.