Runtime safety
Runtime safety controls act on agent execution while it runs, not on configuration. This page describes the controls of ADR-0041.
Suspension and kill switch
Section titled “Suspension and kill switch”The risk
Section titled “The risk”An agent can start to do damage after it is deployed. For example, it can send repeated messages to customers, call a toolkit with a leaked credential, or use a large part of the budget in a loop. Deleting the agent or editing its configuration is too slow, and it does not stop the runs that are in flight. An operator must be able to stop the execution immediately, at the correct scope, and start it again when the incident is closed.
What is enforced
Section titled “What is enforced”A suspension has one of four scopes:
| Scope | What it stops | Who can create it |
|---|---|---|
agent | Every run of one agent in its project | A project admin of that project, or a platform administrator |
project | Every run in one project: agent chats, pipeline runs, index runs, direct toolkit runs | A project admin of that project, or a platform administrator |
toolkit_type | Every call of one toolkit type (for example github) on the platform | A platform administrator |
platform | Every run on the platform | A platform administrator, who must also type STOP |
When a suspension is created, the platform does these steps:
-
Admission refuses new runs. Every path that starts or continues a run checks the active suspensions before it writes anything: a chat turn, a continue or regenerate, a pipeline trigger or schedule, an index run, a direct toolkit run, a toolkit tool call, a desktop child turn, and the support assistant. A covered run is refused with HTTP 409 and the typed error
execution_suspended. The error names the scope, but not the reason:{"error": "An administrator stopped this agent.","code": "execution_suspended","message": "An administrator stopped this agent.","scope": "agent","scope_key": "31","suspension_id": 12}A scheduled pipeline run that is refused is recorded as
failedwith the detailskipped: an administrator suspended execution (execution_suspended). -
Runs in flight are cancelled. In the same database transaction that saves the suspension, the platform sets the desired state of each covered run to
CANCELLED. This is the same cancel that the admin Tasks page uses. The worker stops the run at its next lease renewal. Thecancelled_runsfield of the suspension gives the number of runs it cancelled. A platform suspension cancels every run in flight. Atoolkit_typesuspension cancels every run in flight that holds a toolkit of that type, including toolkits of the run’s sub-agents: each run records its toolkit types when it is admitted. A run that is being admitted while the suspension is saved is either refused or cancelled: admission and the suspension are serialised in the database, so no run escapes both. -
Toolkit calls are refused at the worker. For the
toolkit_typescope, the suspended types are frozen into the guardrail policy of each new run. The worker removes the tools of a suspended type from the agent’s tool set, and it refuses a call of that type with the codeexecution_suspended. Standalone toolkit tool calls of that type are refused at admission.
Each replica of the main service keeps a copy of the active suspensions. A change is sent to all replicas on the NATS subject elitea.rt.v1.safety.suspension, and each replica also reads the table again after 10 seconds at most, so a lost message does not extend the 10-second stop target. If a replica cannot read the table:
- with a copy that it read before, it keeps that copy and tries again after one second;
- with no copy (for example, just after it started), it refuses every run with HTTP 503
suspension_state_unavailable. It does not admit a run that a suspension could cover.
A suspension stays active until it is resumed. Resuming lets new runs start again. Runs that were cancelled stay cancelled; start them again if necessary.
Limits
Section titled “Limits”- An agent suspension cancels only the runs that were admitted after migration 0194, because older runs do not record their agent. The project and platform scopes cancel all runs.
- A run admitted before migration 0201 did not record its toolkit types. A
toolkit_typesuspension reads them from the run’s stored input, which names only the run’s own toolkits, not those of its sub-agents. To be sure that such a run stops, also suspend its project or the platform. - A suspension, like a run cap, takes effect at the run’s next lease renewal, not instantly.
- A cancelled run ends with the state
cancelled, not withexecution_suspended. - Desktop local agent loops run on the user’s computer. A suspension refuses their next cloud-side turn, but it does not stop a loop that is running locally.
Configure and use
Section titled “Configure and use”Stop one agent. Open the agent, then select Stop agent next to Delete. Type the reason, then select Stop agent in the dialog. The page then shows Agent stopped with Resume agent. If the whole project is suspended, the page shows Project stopped.
Every scope. Open Admin › Kill switch. The page lists the active suspensions and refreshes every ten seconds. Select Suspend, then select the scope, the target, the reason, and who can resume it. To stop the whole platform, select the scope Platform and type STOP. Select Resume on a row to end that suspension. Turn on Show resumed suspensions to see the history.
API. The same operations are available with a personal API token:
| Method and path | Purpose |
|---|---|
GET /api/v2/admin/execution_suspensions?active=true | List the suspensions (filter project_id is optional) |
POST /api/v2/admin/execution_suspensions | Create a suspension of any scope |
POST /api/v2/admin/execution_suspensions/{id}:resume | Resume a suspension |
GET /api/v2/elitea_core/execution_suspensions/prompt_lib/{project_id} | List the suspensions of a project |
POST /api/v2/elitea_core/execution_suspensions/prompt_lib/{project_id} | Suspend an agent of the project, or the project |
POST /api/v2/elitea_core/execution_suspensions/prompt_lib/{project_id}/{id}:resume | Resume a suspension of the project |
The body of a create is {"scope", "project_id", "application_id", "toolkit_type", "reason", "resume_requires", "confirm"}. Only the fields of the scope are necessary. project_id and application_id must be between 1 and 2147483647; any other value is refused with 400. A second suspension of the same target is refused with 409 already_suspended, and the answer includes the active suspension.
Self-test. The routine main.kill_switch_selftest runs once a day by default. It suspends a synthetic agent, checks that admission refuses it within the stop target of 10 seconds, resumes it, and checks that admission accepts it again. It does not stage a real run. It does not run while maintenance mode is on. Change its schedule, or turn it off, in Admin › Schedules & Tasks. Each pass writes an audit event. A successful pass sets the Prometheus gauge elitea_kill_switch_selftest_last_success_timestamp (Unix seconds). Alert when the gauge is older than two schedule periods.
The permission safety.suspend controls every route. Migration 0194 grants it to:
- the platform roles
super_adminandadmin(the administration mode): every scope, on the admin routes; - the project role
admin(the default mode), and each saved projectadminrole: theagentandprojectscopes of that project, on the project routes.
Each suspension has a resume rule:
resume_requires | Who can resume |
|---|---|
any_admin (default) | Any caller with safety.suspend on a surface that can see the suspension |
governance_role | A platform administrator only |
two_person | A platform administrator who is not the person who created the suspension. The database also enforces this rule. For a suspension that the platform created (the circuit breaker), two different platform administrators: the first records an approval, the second resumes. |
A project admin can resume only an any_admin suspension of their own project. A suspension made by a platform administrator for a project is visible on the project routes, but its rule can keep the project admin from resuming it.
Evidence
Section titled “Evidence”Every create and resume is written to the audit trail (centry.audit_events) as part of the request record. The action is safety.suspension.created or safety.suspension.resumed, the entity type is execution_suspension, and the entity name is the scope and its key. Example:
{ "timestamp": "2026-10-28T09:15:02.418Z", "user_id": 4, "user_email": "ops@example.com", "project_id": 7, "event_type": "api", "action": "safety.suspension.created", "http_method": "POST", "http_route": "/api/v2/elitea_core/execution_suspensions/prompt_lib/{projectID}", "status_code": 201, "is_error": false, "entity_type": "execution_suspension", "entity_id": 12, "entity_name": "agent:31"}The self-test writes a schedule event with the action safety.kill_switch.selftest, for example the entity name refused=true resumed=true stop_latency_ms=3, and is_error set when a pass fails. The table elitea_runtime.execution_suspensions keeps every suspension with its reason, the user who created it, the time, the number of runs it cancelled, and who resumed it, when, and why. Rows are not deleted.
Customer responsibility
Section titled “Customer responsibility”- Decide who in your organization can stop and resume execution, and give
safety.suspendonly to those people. Remove it from project roles if only the platform team may stop agents. - Use
two_personfor platform suspensions if your change process needs a second approver to restart production. - Monitor
elitea_kill_switch_selftest_last_success_timestampand thesafety.kill_switch.selftestaudit events. - Keep the emergency stop runbook in your incident procedure, and practise it.
- After you resume, start again the runs that were cancelled, if they are still necessary.
Agent circuit breaker
Section titled “Agent circuit breaker”The risk
Section titled “The risk”An agent can fail again and again without a person who sees it. For example, a tool starts to refuse every call, the model loops on the same failing tool, or the agent spends much more than usual. Each run fails, but new runs continue to start, and each run uses the budget and calls external systems. A person who reads a dashboard sees the problem too late. The platform must stop such an agent by rule, and keep it stopped until a person examines it.
What is enforced
Section titled “What is enforced”The routine main.agent_breaker runs every minute. It reads the agent runs that ended since the last pass, and reads each run one time only. A run that ended less than 30 seconds before the pass waits for the next pass. Then the routine checks each agent against four rules:
| Rule | Trips when | Default |
|---|---|---|
error_rate | More than this percentage of the agent’s latest ended runs failed. The window is the latest 20 runs. | On: more than 50 % of 20 runs |
tool_failure_streak | One tool failed this many times in a row inside one run. A call of a different tool between two failures does not stop the count. A success of the same tool does. | On: 5 failures |
spend_per_hour | The agent’s model and metered tool spend in the last hour is more than this amount (USD) | Off |
runs_per_hour | More runs of the agent than this ended in the last hour | Off |
When a rule trips, the breaker writes a suspension of the source breaker at the agent scope. This is the same suspension that the kill switch uses:
- admission refuses new runs of the agent with HTTP 409
execution_suspended, and the messageThe agent circuit breaker stopped this agent. An administrator must resume it.; - the runs of the agent that are in flight are cancelled in the same transaction;
- the suspension records the rule, the observed value, the limit and the agent’s risk tier.
The breaker does not trip an agent that is already suspended, by any source. After a resume, the window starts again: runs admitted before the resume do not count. Its first pass after the upgrade reads only the runs that ended in the 10 minutes before. Older runs are not judged.
An agent with fewer ended runs than the window since its last resume is not judged on error_rate. A cancelled run does not count as a failure or as a run in the window.
A run cap stop is not a failure. A run that a run cap stopped (CAP_REACHED) does not count as a failure or as a run in the error_rate window. This applies to all caps: the wall time and spend caps that Main applies, and the tool call, model turn and records caps that the worker applies. A tool call that the worker refused because the run reached a cap is not a failure of that tool, so it does not add to tool_failure_streak. Thus an agent that does long tasks and often reaches its caps is not stopped by the breaker for that reason.
Who can resume depends on the agent’s risk tier:
| Tier | resume_requires | Who can resume |
|---|---|---|
low, medium | any_admin | A project admin of the project, or a platform administrator |
high | governance_role | A platform administrator |
critical | two_person | Two different platform administrators. The first Resume records an approval and answers HTTP 202; the suspension stays active. A second platform administrator selects Resume to end it. The same administrator cannot do both steps (HTTP 403 second_person_required). The database also enforces this rule. |
Many agents at the same time. When a model provider or a shared tool is down, many agents fail together. Stopping all of them would turn a short outage into a long one, because each agent then needs a person to resume it. If more agents than the storm limit (default 5) would trip on error_rate or tool_failure_streak in one pass, the breaker stops none of them on those rules. It raises the EliteaAgentBreakerStorm alert instead. The spend_per_hour and runs_per_hour rules still trip.
Limits
Section titled “Limits”- The breaker acts once a minute, on runs that ended. It does not stop a run in the middle of its first failures. Use run caps and project budgets for limits inside one run.
tool_failure_streakreads the run step records. A run whose worker records no tool steps is not checked on that rule.spend_per_hourreads the gateway request log. Spend that the gateway does not attribute to a run is not counted. Each pass reads only the agents whose spend in the last hour is more than their own limit (the tier default, or the tighter override).- An agent’s limits apply to the agent in all its runs. The breaker has no limit per user.
Configuration
Section titled “Configuration”Open Configuration › Circuit Breaker (permission configuration.governance). The section has:
- Circuit Breaker On: on by default. Turn it off to stop all passes.
- Error Rate Window (runs): the number of latest ended runs the error rate reads (5 to 200, default 20).
- Storm Limit (agents per pass): default 5. 0 turns the storm limit off.
- For each risk tier: Error Rate (%), Spend per Hour (USD), Same Tool Failing in a Row, Runs per Hour and Who Can Resume. 0 turns a rule off.
The routine reads the section at each pass, so a change applies within one minute. Change the routine’s schedule, or turn it off, in Admin › Schedules & Tasks. It does not run while maintenance mode is on.
One agent. The agent’s registry record can set tighter limits than its tier. It cannot set looser limits. Use the registry API with the field circuit_breaker:
curl -sS -X PUT "$ELITEA/api/v2/elitea_core/agent_registry/prompt_lib/12/31" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"circuit_breaker":{"error_rate_percent":30,"spend_per_hour_usd":5}}'A value looser than the tier default is refused with HTTP 422 circuit_breaker_override_loosens, and fields names the looser limits. A smaller error_rate_window_runs is tighter. {} clears the override. Each change writes the security audit action agent.registry.breaker_override_changed.
Evidence
Section titled “Evidence”Each trip writes, in the same transaction as the suspension:
- the suspension row in
elitea_runtime.execution_suspensions, withsource = breaker,actor_id = 0and the rule indetail; - one security audit event of the type
ai.elitea.security.v1.agent.breaker, in the hash-chained security audit log. The audit export sends it to your SIEM.
Example event:
{ "type": "ai.elitea.security.v1.agent.breaker", "subject": "agent/31", "time": "2026-10-30T15:30:11.187Z", "data": { "action": "agent.breaker.tripped", "principal_ref": "system:agent_breaker", "tenant_project_id": 12, "resource_type": "agent", "resource_id": "31", "reasons": [ "rule:error_rate", "observed:55", "threshold:50", "unit:percent", "tier:high", "resume_requires:governance_role", "suspension:41", "window_runs:20", "failed_runs:11" ] }}The agent’s owners and the project admins get a notification with the rule and the observed value. Admin › Kill switch shows each breaker suspension with the label Circuit breaker, the rule, the observed value and the limit. The agent’s page shows Stopped by circuit breaker. The suspension API returns the same values in the field breaker.
The resume is written to the audit trail with the action safety.suspension.resumed. The first approval of a two_person resume is written with the action safety.suspension.resume_approved.
Metrics and alerts. elitea-main serves these Prometheus metrics on its metrics port (ELITEA_AUDIT_EXPORT_METRICS_ADDR, port 9464), also when the audit export is off. No series has a project or an agent label.
| Metric | Type | Meaning |
|---|---|---|
elitea_safety_suspensions_active{scope,source} | gauge | Active suspensions. Every replica reports the same count. |
elitea_breaker_trips_total{rule} | counter | Agents the breaker stopped, by rule |
elitea_breaker_storms_total | counter | Passes that stopped no agent because of the storm limit |
elitea_breaker_pass_failures_total | counter | Breaker passes that failed |
The Helm chart ships these alerts in a PrometheusRule wherever the Prometheus Operator API is available (main.safetyAlerts, on by default):
| Alert | Severity | Fires when |
|---|---|---|
EliteaPlatformExecutionSuspended | critical | A platform suspension is active for 1 minute. The scheduled self-test is not included. |
EliteaAgentBreakerStorm | critical | A pass hit the storm limit |
EliteaAgentBreakerTripped | warning | The breaker stopped an agent in the last 10 minutes (label rule) |
EliteaExecutionSuspensionsActive | warning | An agent, project or toolkit type suspension stays active for main.safetyAlerts.prometheusRule.activeFor (default 30 minutes) |
EliteaAgentBreakerPassFailing | warning | 5 or more passes failed in 15 minutes |
Customer responsibility
Section titled “Customer responsibility”- Paging and on-call stay with you. Elitea stops the agent and raises the alert. Route the alerts to your incident-management tool, and decide who is on call for agents. Elitea does not page a person.
- Set the limits to match your agents. A low error-rate limit stops agents during short provider outages; a high one lets a broken agent run longer.
- Set spend and runs limits for agents that can spend money or send messages, because they are off by default.
- Decide who examines a stopped agent and who resumes it. For
criticalagents, keep two platform administrators available. - Before you resume, find the cause: read the run step records of the failed runs, and correct the agent, the tool or the credential.
- Keep the Prometheus Operator, or scrape port 9464 with your own monitoring, so that the alerts can fire.
Run caps
Section titled “Run caps”The risk
Section titled “The risk”The worker limits each turn of a run, but a long autonomous run can continue for many turns. Without limits on the whole run, a looping agent can keep running, calling tools, reading records and spending for days.
What is enforced
Section titled “What is enforced”A run cap is the most that one agent run can use. There are five caps:
| Cap | Counts | Enforced by | Default |
|---|---|---|---|
max_wall_seconds | Time from the run’s original start | Main, at each lease renewal | 0 (off) |
max_tool_calls | Calls of toolkit tools, MCP tools included | Worker, before each call | 200 |
max_model_turns | Model calls | Worker, before each call | 0 (off) |
max_spend | Model and metered tool spend, in USD | Main, every 30 seconds | 0 (off) |
max_records_read | Records that the counting tools return | Worker, after each call | 0 (off) |
max_wall_seconds is one platform-wide value. The other four have a value for each risk tier (low, medium, high, critical). The tier is the root agent’s tier from its registry record. An agent without a record, and an ad hoc chat, use the low tier. A nested agent or a pipeline node uses the caps of the root run, and its calls count against them.
Author caps. An agent author can set lower caps for one agent in the version’s meta.run_caps, for example {"max_tool_calls": 50, "max_spend": 0.5}. The lower value of the tier cap and the author cap applies. An author cannot raise a cap: a save with a value above the tier cap fails with HTTP 422:
{ "code": "RUN_CAP_ABOVE_TIER", "cap": "max_tool_calls", "tier": "high", "tier_cap": 80, "requested": 100, "error": "meta.run_caps.max_tool_calls is 100, above the platform cap 80 for high tier agents: an agent can lower a run cap, never raise it" }A value that is not a positive number in the cap’s range fails with RUN_CAP_INVALID.
Step limit. An agent’s step_limit still limits each agent loop. With max_model_turns, the lower of the two stops the run first.
What is counted.
-
Tool calls: every call of a toolkit tool or an MCP tool, in every agent and pipeline node of the run. Platform tools that are not toolkit tools (the planner, attachments, an agent called as a tool) are not counted. The toolkit calls that such an agent makes are counted.
-
Model turns: every model call of the run’s agents. Summary and compaction calls are not counted.
-
Records read: only these tools report a count. Other tools count 0.
Toolkit type Tool Counted items jirasearch_using_jqlIssues returned githubsearch_issuesIssues and pull requests returned sqlexecute_sqlRows returned confluencesearch_pages,search_by_titlePages returned Every index toolkit search_indexDocuments returned -
Spend: the run’s rows in the gateway request log. A model call costs its tokens at the price catalogue rate. A metered tool costs its recorded cost.
When a run reaches a cap. The run stops and ends with a typed error. A worker cap ends the run as failed. A Main cap (max_wall_seconds, max_spend) cancels the run with the reason cap_reached. The run’s event and the agent’s answer carry:
{ "code": "CAP_REACHED", "cap": "max_tool_calls", "retryable": false }The answer shows a message that names the cap, for example “The run stopped because it reached the maximum number of tool calls an administrator set. The task may be incomplete. Check what it completed before you continue.”
Override. A user with the permission safety.override_cap can let the conversation continue past the cap one time. The platform administrators and the project admin role have this permission. The user selects Allow the next run past this cap on the stopped answer (or calls POST /api/v2/elitea_core/run_cap_overrides/prompt_lib/{project_id} with message_id). The next agent run of that conversation gets twice the cap that stopped the answer. The other caps do not change. One override is permitted for each stopped answer, and one unused override for each conversation. An unused override expires after one hour. After it expires, a user can give a new override on a different stopped answer in that conversation. A worker cap cannot be overridden until an administrator saves the per-tier caps (HTTP 422 tier_caps_not_saved). If an administrator removes the per-tier caps after an override on a worker cap, the next run does not use that override, because the worker does not get the caps. The override stays available until it expires.
Limits
Section titled “Limits”max_wall_secondstakes effect at the next lease renewal, so a run can pass it by up to one renewal interval.max_spendis checked every 30 seconds after the gateway records each call. A run can pass the cap by what it spends in that interval, plus the time to its next lease renewal. A single model call that is in progress is not stopped part way.- Spend is in USD, the currency of the price catalogue. A model without a price costs 0.
- Records read counts only the tools in the table above.
- Counters are per execution. A run that continues after an approval pause is a new execution, and its tool calls, model turns, records and spend start at zero. Its wall time counts from the original start.
- Each new message in a conversation starts a new run with its own caps.
- When a cap trips, a cancel that arrives later still ends the run with
CAP_REACHED.
Configure
Section titled “Configure”Under Configuration › Run Caps, a platform administrator (permission configuration.governance) sets:
- Maximum Run Time (seconds): 0 (off, the default), or 60 to 604800 (7 days).
- For each tier: Maximum Tool Calls, Maximum Model Turns, Maximum Spend (USD) and Maximum Records Read. 0 is no cap. Counts are whole numbers. Spend has at most four decimals.
Until an administrator saves a per-tier value, the worker applies its defaults (200 tool calls, no other worker cap), and Main does not send caps to the worker. Save the per-tier caps only after every worker runs this release. A worker of an earlier release refuses commands that carry caps.
Evidence
Section titled “Evidence”elitea_runtime.execution_jobs.run_capsrecords the effective caps of each run, andoverridenames the cap that an override raised.cancel_capnames the Main cap that cancelled a run.elitea_runtime.run_cap_overridesrecords each override: the answer, the cap, who granted it, when, and the run that used it.lapsed_atrecords when an override that expired unused was closed.- Each override is a security audit event, in the same transaction as the override (excerpt):
{ "type": "ai.elitea.security.v1.agent.cap.override", "data": { "action": "agent.cap.override.granted", "effect": "allow", "tenant_project_id": 12, "principal_ref": "user:7", "execution_id": "0f6c…", "resource_type": "chat_message", "resource_id": "0190f2a6-7d1c-7cc1-9a35-3f4b4d2a1b11", "decision_id": "41", "reasons": ["cap:max_tool_calls", "raise:double", "ttl_seconds:3600"] }}- The request’s admin audit record has the action
safety.run_cap_override.granted. - The metric
elitea.runtime.run_caps.reachedwith the labelcapcounts the runs that Main stopped (max_wall_seconds,max_spend). The worker logsagent_run_cap_reachedwith the cap, the count and the limit for a worker cap. - The run’s step record has the
CAP_REACHEDerror and its cap.
Customer responsibility
Section titled “Customer responsibility”- Choose caps above your longest legitimate run at each tier, including the time that people take to approve steps. Lower the caps for the high and critical tiers.
- Give
safety.override_caponly to the people who can decide that a run may use more. Review theagent.cap.overrideevents. - Alert on
elitea.runtime.run_caps.reachedand on the worker’sagent_run_cap_reachedlog event if a stopped run needs attention. - Keep the price catalogue current, so that
max_spenduses correct prices. - Use project budgets for spend across runs. A run cap limits one run only.
Approval evidence and unattended runs
Section titled “Approval evidence and unattended runs”The risk
Section titled “The risk”A sensitive tool, a pipeline review node or a pipeline node with interrupt_before or interrupt_after (a static pause) pauses a run until a person approves or continues the step. An auditor must be able to prove who approved each step, what the step was, and when. A record that only the chat window shows changes with the conversation, and it is not tamper-evident. A run that a schedule or a webhook starts has no person to approve a step. If such a run approves a step on its own, the approval control does not apply to automated runs.
What is enforced
Section titled “What is enforced”Every approval is a ledger row. The table elitea_runtime.approval_ledger (migration 0211) records each event of each approval:
| Phase | Outcomes |
|---|---|
pause | paused: the run stopped at the step |
notice | unattended_waiting: an unattended run waits, and the platform notified its owner |
decision | approved, rejected, edited, answered, other, timed_out, cancelled, superseded, unattended_failed, unattended_skipped |
Each row records the actor (a user, or the platform), the tool, the toolkit, the digest of the tool arguments, the decision and the time. The arguments themselves are not recorded. Rows cannot be changed: a database trigger refuses an update.
The worker calculates the argument digest before it masks the arguments, and sends it with the pause: in the pause of the agent itself, and in display.argument_digest of a card of a parallel or fan-out run. For an agent, the digest is sha256: followed by the SHA-256 of the canonical JSON (keys in sorted order, no spaces) of the arguments that the tool runs with. For a pipeline node, the digest is the digest that the pipeline binds to the call. The masked arguments that the chat window shows are not the evidence, and the platform does not calculate a digest from them. Thus argument_digest is empty (NULL) in two cases:
- a pause from a worker of an earlier release;
- a static pause, which has no tool call and no arguments.
The platform writes a row on every path that pauses or decides a step:
- a decision in the chat window, or with the continue API (a pause of the agent itself);
- a static pause of a pipeline (
interrupt_beforeorinterrupt_after), and the continue of that pause (actioncontinue, outcomeapproved). This includes a static pause in a pipeline that an agent calls as a tool; - a decision on one card of a parallel or fan-out run, with the per-interrupt decisions API;
- a confirmation of a sensitive tool from the desktop app;
- a Stop, a regenerate or a new message that ends a pause (
cancelledorsuperseded); - the end of a run that leaves a card open (
cancelled, reasonrun_ended); - the expiry of an unattended wait (
timed_out).
The row is written in the same database transaction as the decision. If the row cannot be written, the decision is refused.
One winner. A step has one decision. When two people decide the same step at the same time, the platform applies one decision, and the other person gets HTTP 409. A person who answers after the step was decided, timed out or skipped also gets HTTP 409.
Every row is an audit event. In the same transaction, each row is written to the security audit outbox as one ai.elitea.security.v1.agent.approval event, at the next position of the hash chain of the project. These events are written for every run. They do not need the credential governance profile.
The chat window shows the ledger. The pending steps in the message metadata (hitl_interrupts) are a view for the UI. The ledger is the record. Each pending step in the chat window has a pause row, and each decided step has a decision row.
Unattended runs never approve. When a run that a schedule or a webhook trigger started gets to a step that needs approval, the platform does one of three things, set by on_approval_required:
| Value | What happens | Ledger outcome |
|---|---|---|
fail (default) | The run ends with the error approval_required_unattended. | unattended_failed |
skip | The platform refuses the step. The model receives a tool result that says the step was skipped because the run has no approver, and continues without the step. | unattended_skipped |
notify_and_wait | The run pauses, and the run’s owner gets a notification. The owner can approve or reject the step (or continue a static pause) in the conversation. If nobody answers in time, the run ends with the error approval_timed_out, and the pause cannot be continued. | unattended_waiting, then the decision or timed_out |
No value approves a step. These pauses use fail, whatever the value is:
- a
skipof a step that has no refusal action. A static pause (interrupt_beforeorinterrupt_after) has only the actioncontinue, so an unattended static pause withskipusesfail, and the pause cannot be continued; - an unattended pause that also waits for a credential authorization;
- a
skipor anotify_and_waitpause of a run whose owner the platform cannot find. The platform cannot notify such a run’s owner, and it cannot continue the run as the owner. The run ends with the errorapproval_required_unattended, and the ledger row has the outcomeunattended_failedand the reasonapproval_required_unattended.
On a skip, the platform is the actor of the decision. The ledger row has actor_kind system. On a card of a parallel or fan-out run, the card also names the platform: decided_by is empty and decided_by_system is true, and the DECIDED row of the card audit has system_actor true. The run’s owner is not the actor. The owner is recorded only as the owner of the run (unattended_run_policies.owner_user_id).
A failed expiry does not stop other expiries. The platform expires the waits of notify_and_wait runs in order of their expiry time. If the platform cannot expire one wait, it logs the error, ends that wait with the state FAILED and the failure code expiry_failed, and continues with the next wait. Later waits expire on time. The run of the failed wait stays paused until a person answers it or stops it.
Configuration
Section titled “Configuration”Platform default. Open Configuration › Approvals (permission configuration.governance):
| Field | Values | Default |
|---|---|---|
Unattended Default (approvals.unattended_default) | fail, skip, notify_and_wait | fail |
Unattended Wait (hours) (approvals.unattended_wait_hours) | 1 to 720 | 24 |
Per schedule and per trigger. Set on_approval_required on the schedule or on the trigger of a pipeline version. If you do not set it, the platform default applies. The settings read shows the value that applies.
| Method and path | Field |
|---|---|
PUT /api/v2/pipeline_schedules/prompt_lib/{project_id}/{version_id} | on_approval_required in the body |
POST /api/v2/pipeline_triggers/prompt_lib/{project_id}/{version_id} (create or rotate) | on_approval_required in the body |
A save or a rotation without the field keeps the stored value. Any other value is refused with HTTP 400.
The platform saves the value in the same transaction as the schedule or the trigger. If the save fails, you get HTTP 500, and nothing changes: the schedule keeps its settings, and the trigger keeps its secret, so the secret that you have continues to work.
Each run keeps the value that applied when it started (table elitea_runtime.unattended_run_policies). A later change does not change a run that is in flight.
Evidence
Section titled “Evidence”Read the ledger of a run:
SELECT created_at, path, phase, outcome, action, actor_kind, actor_user_id, tool_name, toolkit_name, argument_digest, reason, audit_event_idFROM elitea_runtime.approval_ledgerWHERE execution_id = '<execution id>'ORDER BY id;audit_event_id is the id of the audit event of the row. This is an example event of an approval:
{ "specversion": "1.0", "id": "01a18ca9-bedb-75cc-a7a3-fa1b173dfca0", "source": "elitea-main", "type": "ai.elitea.security.v1.agent.approval", "subject": "chat_message/6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f", "time": "2026-10-30T09:15:02.418Z", "datacontenttype": "application/json", "dataschema": "urn:elitea:security-audit:v1", "partitionkey": "7", "sequence": "00000000000000000412", "ocsfclassuid": 6003, "ocsfcategoryuid": 6, "data": { "tenant_project_id": 7, "action": "agent.approval.approved", "effect": "allow", "reasons": ["path:root", "origin:manual", "guardrail:sensitive_tool", "action:approve"], "principal_ref": "user:4", "actor_chain": [], "actor_fidelity": "attributed", "execution_id": "4b7c2d1e9f0a4b3c8d7e6f5a4b3c2d1e", "invocation_id": "interrupt-root-1", "decision_id": "1873", "resource_type": "chat_message", "resource_id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f", "authorization_epoch": 0, "revocation_epoch": 0, "tool_name": "create_issue", "toolkit_name": "jira", "arguments_digest": "sha256:9c1f0e5b2d7a4c3e8f6b1a0d9e2c7b4a5f3e1d0c9b8a7f6e5d4c3b2a1f0e9d8c" }}An event of the platform has the principal_ref system:elitea-main. The security audit validator refuses a value that looks like a credential (for example, a JWT or a Bearer value), a control character, or a value longer than 256 bytes. When a tool name, a toolkit name or an interrupt ID has such a value, the event records the digest of the value (sha256: and 64 hex characters) in its place. The approval is not refused because of the name. An unattended outcome has the reason approval_required_unattended, approval_skipped_unattended or approval_timed_out. To verify the chain, use the security audit verifier (see the evidence guide). To send the events to your SIEM, use the audit export.
What is not enforced
Section titled “What is not enforced”- A rejection in the desktop app occurs on the user’s computer and sends nothing to the server. The ledger has the
pauserow of the step, and nodecisionrow. - A credential authorization pause (a toolkit that needs the user to sign in) is not an approval. It is not in the ledger, and
on_approval_requireddoes not apply to it. - When a parallel or fan-out run fails for a missing approver, the members that are already running continue until the worker sees the cancellation at its next lease renewal.
- Pauses that started before migration 0211 have no
pauserow. A decision on such a pause is recorded.
Customer responsibility
Section titled “Customer responsibility”- Keep
approvals.unattended_defaultatfail, unless a reviewed process needsskipornotify_and_wait. - Use
notify_and_waitonly when the owner of the schedule or trigger can answer before the wait expires. - Monitor
elitea_runtime.unattended_approval_actionsfor rows with the failure codeexpiry_failed, and stop or answer those runs. - Send the
agent.approvalevents to your SIEM, and alert onunattended_failedandtimed_out.
Tool categories and approval policy
Section titled “Tool categories and approval policy”The risk
Section titled “The risk”A list of sensitive tools names each tool one by one. A new toolkit, or a new tool in a toolkit, does not ask for approval until someone adds it to the list. An agent that can delete records, post to people outside the organisation or move money can then act without a person.
What is enforced
Section titled “What is enforced”Each tool is in one category:
| Category | Meaning |
|---|---|
read | Reads data and changes nothing. |
write | Creates or changes data. A tool that is not declared is write. |
delete | Deletes data. |
external_send | Sends something that people outside the run see: an email, a chat post, a ticket or pull request comment. |
payment | Moves money. |
code_execution | Runs code or a query that the model wrote. |
The categories of the built-in tools are declared in one contract, libs/proto/contracts/tool-categories-v1.json. The worker compiles it in, and the main service reads the same file. The tools that are not read or write:
| Category | Tools |
|---|---|
external_send | GitHub comment_on_issue; GitLab and GitLab Org comment_on_issue, create_pr_change_comment; GitLab comment_on_pr; Bitbucket add_pull_request_comment; Azure DevOps Repos comment_on_pull_request; Aha! add_comment; Jira add_comments, update_comment_with_file; Figma post_file_comment; Slack send_message; Gmail (yagmail) send_gmail_message |
delete | Every delete_* tool, remove_index and OneNote onenote_delete_page |
code_execution | Data Analysis pandas_analyze_data; Sandbox pyodide_sandbox, stateful_pyodide_sandbox; SQL execute_sql; BigQuery execute |
payment | No built-in tool yet |
The tools of a custom MCP server take the category that the approver gave each tool when the connector request was approved (the pinned tools carry it). A tool without a category is write.
A tool call needs a human approval when one of these rules applies:
- Platform rule. The administrator’s sensitive action tools name the tool. This is the list under Sensitive action authorization. It applies at every tier.
- Policy rule. The approval policy names the tool’s category for the risk tier of the run’s agent.
- Read untrusted, then act. The tool’s category is
delete,external_sendorpayment, the rule is on for the risk tier of the run’s agent, and a tool returned a result earlier in the same turn. A tool result can contain an injected instruction, so an action after a read stops at a person. The rule is on by default at thehighandcriticaltiers. See Untrusted content.
The rules only add approvals. An agent author cannot remove an approval that a rule requires. A toolkit setting cannot move a declared tool to another category.
The tier is the risk tier on the root agent’s registry record (a missing record is low). An agent, pipeline or sub-agent that the root agent calls uses the root agent’s tier, not its own.
The default policy is the ADR default:
| Tier | Categories that need approval |
|---|---|
low | None |
medium | None |
high | delete, external_send, payment |
critical | delete, external_send, payment |
Limits
Section titled “Limits”- The policy applies only after an administrator saves Configuration › Approval Policy. Until then, only the platform rule applies, as in earlier releases. Save the section only after every worker runs this release: a worker from an earlier release refuses commands that carry the policy.
- A tool that is not declared is
write. A policy that nameswriteasks for approval on every tool that is not declared, and on every MCP tool without a category. When you port a toolkit, declare the categories of its tools in the contract. - Direct tool calls from outside a run (the platform’s MCP endpoint) do not use the policy. They refuse every tool that is not read-only.
- A change to the policy applies to the commands that the main service sends after the change, at once on the replica that saved it and within 15 seconds on the other replicas.
Configure
Section titled “Configure”Under Configuration › Approval Policy, a platform administrator (permission configuration.governance) sets, for each tier, the categories whose tools need approval. The defaults are the table above.
In the same section, Tiers With the Read Untrusted, Then Act Rule selects the tiers whose runs use the “read untrusted, then act” rule. The default is high and critical. With the default policy, these tiers already ask for an approval of each delete, external_send and payment call, so the default adds no approval. The rule adds approvals only at a tier where you remove these categories from the policy, or where you add the tier to the rule. An empty list turns the rule off.
Evidence
Section titled “Evidence”When a tool call pauses because of a rule, the pause carries the field approval_rule:
{ "source": "policy", "risk_tier": "high", "category": "external_send" }The source is platform for the platform rule, policy for the policy rule, untrusted_read_then_act for the “read untrusted, then act” rule (with the category), and builtin for the cloud workspace’s own approvals (destructive commands, push and pull request). The platform rule is named when both rules apply. The field is on the hitl_interrupt and hitl_interrupts of the chat frame, so the decision record of the pause can name the rule.
Customer responsibility
Section titled “Customer responsibility”- Set each production agent’s risk tier, so the policy applies the correct row.
- Decide which categories need approval at each tier. Add
code_executionathighandcriticalif the model’s code or queries can reach production data. - Keep the platform’s sensitive action tools for single tools that need approval at every tier.
Throttles per agent and per user
Section titled “Throttles per agent and per user”The risk
Section titled “The risk”A rate limit at the LLM gateway counts the calls of a whole project. One agent that loops, or one user who runs many agents, can use all of the project’s calls. The other agents and users of the project then get 429 refusals.
What is enforced
Section titled “What is enforced”A rate limit entry under LLM Governance can name agents (scope.agent_ids) or users (scope.user_ids). Such an entry is a throttle:
- An agent throttle counts the calls of each named agent in its own counter. It applies to every model call that a run of that agent makes, also when the run starts from another project.
- A user throttle counts the calls of each named user in its own counter. The counter is shared across the projects that the entry selects.
- An entry that names agents and users counts each pair of agent and user.
A throttle adds to the project rate limit. It does not replace it. A call must pass the project rate limit, the agent throttle and the user throttle that apply to it. In each of these three groups, the narrowest entry applies.
The gateway checks the agent throttle first, then the user throttle, then the project rate limit. A call that one of them refuses does not use a call of the others. Thus an agent that retries after its 429 does not use the calls of the project. When the project rate limit refuses a call after a throttle counted it, the gateway gives the call back to the throttle. For a very short time between the count and the give-back, a different call to that throttle can see one call too many and get a 429. The gateway never admits a call above a ceiling because of this.
When a counter gets to its ceiling, the gateway refuses the call with HTTP 429, rate_limit_error and rate_limit_exceeded, and the Retry-After header gives the seconds until the minute ends. The message names the agent, user or project limit. A throttle refuses calls only. It does not suspend the agent or the user, and the next minute starts with a new counter.
The gateway knows the agent from the signed execution id of the call. It reads the agent of the run from the run’s admission record. The gateway knows the user from the signed user id of the call. A caller cannot set either value.
Limits
Section titled “Limits”- An agent id is an application id of one project. An entry that names agents must name exactly one project in
scope.project_ids. - An entry can name at most 100 agents and 100 users.
- A call that does not come from an agent run (for example, a chat without an agent) does not match an agent throttle. A call without a user does not match a user throttle.
- The throttles fail open, as the project rate limits do. When the shared NATS counter is not available, or the gateway cannot read the agent of a run, the gateway admits the call and does not apply the throttle. It counts each such call.
- The token ceiling applies to the call after the call that crossed it, as for every rate limit.
Configuration
Section titled “Configuration”Under LLM Governance, a platform administrator (permission configuration.governance) creates a Rate limit entry. Set Requests per minute or Tokens per minute, and then fill Throttled agents (with one project in Projects) or Throttled users. Each id must be a positive whole number. The form shows an error and does not save a list with a different value. The main service refuses an entry that names an agent that is not in that project, or a user that does not exist. It applies the same checks to agent_ids and user_ids at the top level of data, because the gateway reads them there when the entry has no scope object or an empty one. See Budgets and LLM governance.
Evidence
Section titled “Evidence”- Each refusal writes a gateway log line
governance: authored rate limit refused the requestwith the rule, the class (project,agentoruser), the agent, the user and the counts. - The refused call is in the gateway request log with the status
429. GET /governance/statuson the gateway givesrate_limiter.refusedandrate_limiter.degraded.degradedcounts the calls that were admitted without a limit because the counter or the agent lookup failed.
Customer responsibility
Section titled “Customer responsibility”- Set throttles that are larger than the normal load of each agent and user, and smaller than the project rate limit.
- Make sure that the agents and users that you throttle can retry after
Retry-After. - Alert on a growing
rate_limiter.degradedcount. While it grows, no rate limit or throttle is in force. - To stop an agent, use the suspension, not a throttle.
Required approval in pipelines
Section titled “Required approval in pipelines”The risk
Section titled “The risk”A pipeline can call a tool from a toolkit node without a model. If the path to an external_send or payment tool has no hitl node, the pipeline sends the message or makes the payment without a person.
What is enforced
Section titled “What is enforced”Configuration › Approval Policy › Tiers That Require a HITL Node in Pipelines names the tiers where the rule applies. Under the rule, every path from the pipeline’s entry point to a toolkit or MCP node that calls an external_send or payment tool must go through a hitl node. The check follows every edge: transitions, default outputs, decision and router routes, HITL routes, parallel branches, map workers and recovery routes.
A static pause also counts as approval. The run stops before each node that the pipeline’s interrupt_before list names, and after each node that its interrupt_after list names. A person resumes the run. Thus a tool node in interrupt_before is approved, and so is each node after a node in interrupt_after. A tool node in interrupt_after runs before the pause, so it is not approved.
The check also covers saved child pipelines:
- A node that can run a saved child pipeline continues the path into the child. An
agentnode can run the child that it selects. Anllmnode can run each child that itstool_namesgive to the model. - The check examines the child again on each path that reaches such a node. A
hitlnode or a static pause before the node approves the child on that path. If there is no approval on the path, each path in the child must go through ahitlnode. - A pipeline that an agent attaches as a tool, and that no node of a pipeline names, is checked from its own entry point.
- The check goes down three levels of saved pipelines, as the worker does. A deeper composition, or one with a cycle, does not start.
- Each child is checked at the tier of its parent if that tier requires the rule. If not, the child is checked at its own tier.
A pipeline that breaks the rule does not start. The start request returns HTTP 422 with the code PIPELINE_HITL_REQUIRED and a message that names the node, the tool, its category and the path that has no hitl node, for example:
Node "post" calls "send_message", which is a tool in the external_send category. The approval policy requires a hitl node on every path to such a tool for high-tier agents, and the path "draft" -> "route" -> "post" has none. Add a hitl node before "post".The check runs each time the platform admits a run: a chat turn, an ad hoc chat turn, a schedule, a trigger, a regenerate and a resume after a pause. All of these freeze the agent version through one step, and the check is part of that step. Pipelines cannot be published (publish refuses them with pipeline_not_publishable), so the run admission is where the rule applies.
The default is off: no tier requires the node.
Limits
Section titled “Limits”- The check reads the pipeline’s toolkit and MCP nodes. A model node (
llmoragent) that can call anexternal_sendtool is covered by the policy rule at call time, not by this check. - The check uses the tier of the pipeline’s registry record, and of the registry record of each child pipeline. An ad hoc chat has no record and is
low. - The check does not read the tools of a child agent that is not a pipeline. A pipeline that such an agent attaches as a tool is not checked when the run starts.
- If the check cannot read a child pipeline, the run does not start.
Configure
Section titled “Configure”Select the tiers under Tiers That Require a HITL Node in Pipelines. The hardening checklist recommends high and critical.
Evidence
Section titled “Evidence”The refused start returns the error above. No run starts, so no run step is written.
Customer responsibility
Section titled “Customer responsibility”- Add a
hitlnode before each node that sends outside the organisation or pays, in pipelines at the tiers you select. - Set the tiers of pipelines on their registry records.
Nested pauses
Section titled “Nested pauses”A human approval can pause a run inside a nested composition. Examples are an agent that calls an agent or a pipeline, and a pipeline that runs a saved pipeline. Each pause shows one approval card with the path of its callers. The worker keeps a checkpoint at each level. After an approval or a rejection, the paused level continues one time. Then each caller continues. When a person rejects, the tool is not called, and the caller reads the rejection result.
A pause can have a maximum of 3 delegations above it. The delegation depth limit under Configuration › Delegation also applies. The worker does not offer an attached pipeline that can pause in a position that it cannot resume. The worker writes a log entry with the reason pipeline.child_pause_unsupported and a detail code that names the pause kind. The HITL node page lists the pause kinds. To find the pipelines that the worker did not offer, search the worker logs for this reason.