Instructions
The instruction engine is the core of Yuzu’s content plane. Every ad-hoc query, scheduled task, policy check, and remediation action flows through the same pipeline: a YAML-defined InstructionDefinition is dispatched to agents via the existing gRPC wire protocol, tracked by the ExecutionTracker, stored by the ResponseStore, and audited by the AuditStore.
This guide covers everything an operator needs to author, manage, execute, schedule, and govern instructions.
Table of Contents
- Core Concepts
- YAML Authoring
- InstructionDefinition Reference
- InstructionSets
- Parameter Type System
- Result Schema and Aggregation
- Approval Workflows
- Concurrency Model
- Scheduling
- Execution Lifecycle
- Error Codes
- REST API Reference
- Dashboard UI
- Planned Features
1. Core Concepts
Content Hierarchy
ProductPack (distribution boundary — Phase 7)
└── InstructionSet (permission boundary)
└── InstructionDefinition (the atomic unit)
├── Parameter Schema (typed inputs)
├── Result Schema (typed outputs)
└── Execution Spec (plugin + action + concurrency)
A definition can exist standalone (without a set or pack). Sets group definitions and enforce shared permissions. Packs bundle everything for signed distribution.
Design Principles
- Substrate, not scripts. Definitions target stable plugin primitives. OS-specific syscalls stay inside the plugin layer. Content authors never write shell commands.
- Everything is an InstructionDefinition. Ad-hoc commands, scheduled tasks, policy checks, and remediation actions all use the same definition-to-execution-to-response pipeline.
- Typed end-to-end. Parameter schemas validate input before dispatch on
POST /api/instructions/{id}/execute(no other dispatch surface validates yet). Result schemas type output for downstream consumption (ClickHouse, Splunk, CSV export). - Governed execution. Every state-changing action can require approval. Every execution is audited. Every response is persisted.
Two Definition Types
| Type | Purpose | Default Approval | Dashboard Treatment |
|---|---|---|---|
question | Read-only query (inventory, status checks) | auto | No confirmation prompt |
action | State-changing operation (restart, install, delete) | role-gated | Confirmation required |
2. YAML Authoring
All instruction content is authored as YAML documents with apiVersion: yuzu.io/v1alpha1. The server stores the verbatim YAML as the source of truth (yaml_source column) alongside denormalized columns for efficient queries.
Minimal Definition
apiVersion: yuzu.io/v1alpha1
kind: InstructionDefinition
metadata:
id: crossplatform.process.list
displayName: List Running Processes
version: 1.0.0
spec:
type: question
platforms: [windows, linux, darwin]
execution:
plugin: processes
action: list
Saving YAML via API
Cookie-session writes to the instruction routes must carry an Origin (or Referer) naming the server; token-authenticated requests do not.
# Save a definition from a YAML file (form-encoded; the yaml_source field
# carries the document verbatim)
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/instructions/yaml \
--data-urlencode "yaml_source@definitions/process-list.yaml"
# Validate YAML without saving
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/instructions/validate-yaml \
--data-urlencode "yaml_source@definitions/process-list.yaml"
Dashboard YAML Editor
The Instruction Management page provides two authoring modes:
- Form mode — fill in fields through structured inputs with dropdowns and validation.
- Code mode — a CodeMirror editor with YAML syntax highlighting, live validation, and auto-complete for known plugin/action pairs.
Both modes are served as HTMX fragments.
Saving keeps what the YAML does not carry. The editor’s Save writes only the
fields the YAML expresses (name, plugin, action, description, and the optional
type, version, concurrency and approval). Everything else stored on the
definition — its instruction set, enabled flag, TTLs, result schema,
visualization, response templates, platforms, minimum agent version, required
plugins and readable payload — is kept. When the YAML omits type, version,
concurrency or approval, the stored value is kept as well; a value that
differs from the stored one is an intentional edit and is written, and a change
of approval is recorded in the audit detail as approval_mode: <old> -> <new>.
Library list. The Definitions tab lists up to 100 definitions by default.
GET /fragments/instructions takes name, plugin, type and limit (maximum
500); a cut list says “Showing the first N. Narrow the filter.” The “Needs
approval” column and the instruction sets panel appear for callers who hold
InstructionDefinition:Read and InstructionSet:Read respectively.
Page controls follow permissions. New, Edit, Import, Delete and the set
controls appear for callers who hold InstructionDefinition:Write,
InstructionDefinition:Delete, InstructionSet:Write and InstructionSet:Delete
respectively, not for a role. Each capability is probed once per page render, and
a denied probe is audited as a denial, so loading the page as a caller who lacks
a capability writes one denial row for it.
Sets. The sets panel lists each set with the number of definitions in it. New set asks for a name and description. Delete set states that its definitions stay and become unassigned. Each library row has a set select that assigns the definition to a set or clears it.
Response templates. Templates on a library row opens a panel directly under the
library filters, scrolled into view, with that definition’s templates: the built-in
default (never editable) first, then the authored ones. Close empties the panel.
New, Edit and Delete open dialogs that call
/api/v1/definitions/{id}/response-templates. Filters are written one per line as
column | op | value.
Import and export. Import definition takes the JSON document that Export
produces (Ed25519-signed or not); unsigned documents can be refused by policy and
a duplicate id is refused. When the audit record could not be written the page
says so. Export saves the definition as <id>.json.
Dispatch. Selecting a definition on the Executions tab reads its parameters
from GET /api/v1/instructions/{id}; grant_secret and grant_id are write-only
password inputs. The Target select offers a scope expression with Validate
(syntax only) and Preview (needs Infrastructure:Read). Dispatch always
confirms, naming the instruction, plugin:action, type and target; a definition
whose approval mode is not auto says it will be submitted for approval and not
run, and an expression that was not previewed needs DISPATCH typed to send.
3. InstructionDefinition Reference
Full Example
apiVersion: yuzu.io/v1alpha1
kind: InstructionDefinition
metadata:
id: crossplatform.service.inspect
displayName: Inspect Service Status
version: 1.2.0
description: >-
Returns the current state, startup type, and PID of a named service
across Windows (SCM), Linux (systemd), and macOS (launchd).
tags:
- services
- operations
spec:
type: question
platforms:
- windows
- linux
- darwin
execution:
plugin: services
action: inspect
concurrency: per-device
stagger:
maxDelaySeconds: 0
fixedDelaySeconds: 0
minSuccessPercent: 100
parameters:
type: object
required:
- serviceName
properties:
serviceName:
type: string
displayName: Service Name
description: The name of the service to inspect.
validation:
maxLength: 256
verbose:
type: boolean
default: false
result:
columns:
- name: serviceName
type: string
- name: state
type: string
- name: startupType
type: string
- name: pid
type: int32
aggregation:
groupBy: [state]
operations: [count]
readablePayload: "Inspect service '${serviceName}'"
gather:
ttlSeconds: 300
response:
retentionDays: 90
approval:
mode: auto
permissions:
executeRoles:
- endpoint-operator
- endpoint-admin
authorRoles:
- content-author
compatibility:
minAgentVersion: 0.9.0
requiredPlugins:
- services
legacy_shim:
enabled: true
Metadata Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Globally unique ID. Convention: <scope>.<domain>.<action> (e.g. crossplatform.service.inspect). |
displayName | string | Yes | Human-readable name shown in the dashboard. |
version | string | Yes | Semantic version (e.g. 1.2.0). Used for compatibility checks. |
description | string | No | Detailed description of what this definition does. |
tags | list | No | Freeform tags for categorization and search. |
Spec Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type | string | Yes | — | question (read-only) or action (may modify state). |
platforms | list | Yes | — | Target platforms: windows, linux, darwin. |
execution | object | Yes | — | Plugin, action, concurrency, stagger. See Section 8. |
parameters | object | No | {} | Input parameter schema. See Section 5. |
result | object | No | {} | Output column schema. See Section 6. |
readablePayload | string | No | "" | Template for audit display. Supports ${paramName} interpolation. |
gather | object | No | — | ttlSeconds: agent response window (default 300). |
response | object | No | — | retentionDays: how long to keep response data (default 90). |
approval | object | No | — | mode: auto, role-gated, or always. See Section 7. |
permissions | object | No | — | executeRoles and authorRoles arrays. |
compatibility | object | No | — | minAgentVersion and requiredPlugins. |
legacy_shim | object | No | — | enabled: true for auto-generated definitions from plugin descriptors. |
Execution Spec
| Field | Type | Default | Description |
|---|---|---|---|
plugin | string | — | Plugin identifier (must match a registered plugin’s name). |
action | string | — | Action name (must exist in the plugin’s actions[] array). Case-insensitive; normalized to lowercase at creation time and at dispatch. |
concurrency | string | per-device | One of: per-device, per-definition, per-set, global:<N>, unlimited. Only per-device is enforced — see Section 8. |
stagger.maxDelaySeconds | int | 0 | Max random delay per agent before execution. 0 = no stagger. |
stagger.fixedDelaySeconds | int | 0 | Fixed delay per agent before execution, added before the random stagger. 0 = no fixed delay. Total wait = fixedDelaySeconds + random(0, maxDelaySeconds). |
minSuccessPercent | int | 100 | Minimum success percentage. 0 = best-effort. |
Compatibility
| Field | Description |
|---|---|
minAgentVersion | Declared and exported; not compared at dispatch today, so an older agent is NOT skipped and error 4003 is not raised. |
requiredPlugins | Agents missing any listed plugin are skipped (error 4001). |
4. InstructionSets
An InstructionSet groups definitions under a shared permission boundary. Sets control who can execute, author, and approve definitions within them.
Full Example
apiVersion: yuzu.io/v1alpha1
kind: InstructionSet
metadata:
id: core.crossplatform.services
displayName: Cross-Platform Service Management
version: 1.0.0
description: Inspect, start, stop, and restart services across all platforms.
platforms:
- windows
- linux
- darwin
permissions:
executeRoles:
- endpoint-operator
- endpoint-admin
authorRoles:
- content-author
- endpoint-admin
approveRoles:
- endpoint-admin
contents:
instructionDefinitions:
- crossplatform.service.inspect
- crossplatform.service.start
- crossplatform.service.stop
- crossplatform.service.restart
policyFragments: []
workflowTemplates: []
defaults:
approvalMode: role-gated
responseRetentionDays: 30
targetEstimationRequiredAbove: 500
publishing:
signed: true
visibility: org
Set-Level Permissions
| Role Type | Purpose |
|---|---|
executeRoles | Roles permitted to run definitions in this set. |
authorRoles | Roles permitted to create or modify definitions in this set. |
approveRoles | Roles permitted to approve executions in this set. |
Set Defaults
| Field | Default | Description |
|---|---|---|
approvalMode | auto | Default approval mode for definitions that do not specify their own. |
responseRetentionDays | 90 | Default retention period for response data. |
targetEstimationRequiredAbove | — | When estimated target count exceeds this, the UI requires confirmation. |
Managing Sets via API
# Create an instruction set
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/instruction-sets \
-H "Content-Type: application/json" \
-d '{
"name": "Core Service Operations",
"description": "Service inspect, start, stop, restart"
}'
# Delete a set (cascades to contained definitions)
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X DELETE http://localhost:8080/api/instruction-sets/abc123
5. Parameter Type System
Parameters use a JSON Schema subset. The root object describes the input shape; each property declares a typed parameter with optional validation.
Supported Types
| Type | Description | Wire Format | Example |
|---|---|---|---|
string | UTF-8 text | String | "Spooler" |
boolean | Boolean flag | true / false | true |
int32 | 32-bit signed integer | Numeric string | "42" |
int64 | 64-bit signed integer | Numeric string | "9223372036854775807" |
datetime | ISO 8601 timestamp | String | "2026-03-17T18:20:00Z" |
guid | UUID / GUID | String | "550e8400-e29b-41d4-a716-446655440000" |
Parameters are transmitted as map<string, string> in the CommandRequest protobuf message. POST /api/instructions/{id}/execute validates the request’s params against the declared types and constraints before dispatch (see Parameter validation); no other dispatch surface does yet.
Validation Constraints
| Constraint | Applicable Types | Description |
|---|---|---|
maxLength | string | Maximum length in bytes (UTF-8). |
minLength | string | Minimum length in bytes (UTF-8). |
pattern | string | Regex the value must match. |
enum | string | List of allowed values. |
minimum | int32, int64 | Minimum value (inclusive). |
maximum | int32, int64 | Maximum value (inclusive). |
YAML Example
parameters:
type: object
required:
- serviceName
- action
properties:
serviceName:
type: string
displayName: Service Name
description: The system service name (not display name).
validation:
maxLength: 256
pattern: "^[a-zA-Z0-9_.-]+$"
action:
type: string
displayName: Action
description: What to do with the service.
validation:
enum: [start, stop, restart]
timeout:
type: int32
default: 30
description: Seconds to wait for the operation.
validation:
minimum: 5
maximum: 300
verbose:
type: boolean
default: false
Parameter validation
POST /api/instructions/{id}/execute validates the request’s params against the definition’s stored
parameter_schema before the approval gate, strictly (details and error bodies: REST API).
A refused call creates no approval ticket, execution row or dispatch. Enforced as declared:
- a name the schema does not declare is refused (path
/*); a required name without adefaultmust be sent; adefaultsatisfiesrequiredbut is not injected, so the plugin still receives only what you sent; type,enum(case-sensitive),pattern(anchor it: it is a search, not a full match),minimum/maximum(int32also gets the 32-bit range) andminLength/maxLength;- an integer is a JSON integer or a string of digits with an optional leading
-("+5"," 5","5.0"and JSON5.0are refused); a boolean istrue/falseor the strings"true"/"false"; nullis never read as an omission: a parameter refuses it (omit the key instead);- a string containing a NUL character is refused.
A definition that stores no schema (empty, whitespace or {}, which is what the YAML editor stores for a
definition created there) is not validated. The YAML editor, PUT /api/instructions/{id} and
response-template changes keep the stored schema: editing spec.parameters in the YAML editor does not
change what execute enforces.
Limits on a stored schema (a schema over a limit is refused when it is written, and a stored one that is
over a limit makes execute answer 500):
| Limit | Value |
|---|---|
| schema text | 262144 bytes |
| properties per schema | 128 |
members per enum | 256 |
pattern length | 1024 bytes |
| compiled pattern size | 32768 RE2 instructions each, 65536 per schema, and RE2’s 512 KiB memory budget per pattern |
integer minimum/maximum | magnitude below 2^53 |
string matched against a pattern | 64 KiB |
matching work per request (or per schema, for defaults) | (bytes + 1) x pattern size, plus one unit per enum member compared, up to 16777216 |
enabled: false hides a definition from the discovery catalog and stops its schedules (the schedule
poller records definition_disabled), but it does not stop POST /api/instructions/{id}/execute:
that route does not read enabled.
Replacing a stored parameter schema
No route replaces a stored schema in place: PUT /api/instructions/{id}, the YAML editor and
response-template changes keep it, and POST /api/instructions/import answers 409 for an id that
already exists. To replace one (a wrong or legacy schema, a 500 from execute, or a corrected bundled
definition on an existing install), export the definition, delete it and import it again with a
corrected parameter_schema:
GET /api/instructions/{id}/export(InstructionDefinition:Read) and keep the document. It carries the fields import reads, includingyaml_source,instruction_set_idandresponse_templates_spec, which the delete removes (the delete and re-import also resetcreated_at, which import never reads). It carries nosignatureorpublicKey.- Edit its
parameter_schema(a string holding the schema JSON). Unless the server runs with--allow-unsigned-definitions, the edited document must also be signed before import, or the import answers400as unsigned: add a hexsignatureandpublicKey(an Ed25519 signature over the exact bytes ofyaml_source, 128 and 64 hex characters). The signature coversyaml_sourceonly, soplugin,action,type,approval_modeandconcurrency_modeare read from it and must agree with the envelope. A definition whoseyaml_sourceis empty cannot be signed (import answers an error about a signature withoutyaml_source), so only the unsigned flag works for it. - Rehearse the import, because step 5 cannot be rehearsed (an existing id answers
409) and a refused import leaves the definition deleted:POST /api/instructions/importthe corrected, signed document once with a differentid(import readsidfrom the document and accepts any id that is free), confirm it is accepted, thenDELETEthat temporary id. The signature covers only theyaml_sourcebytes, so it stays valid. The temporary definition is live until it is deleted. DELETE /api/instructions/{id}(InstructionDefinition:Delete, audit actioninstruction.delete).POST /api/instructions/importwith the edited document (InstructionDefinition:Write, audit actioninstruction.import). The schema is checked as it is written, so a bad one is refused with a400.
The signature covers only the yaml_source bytes (not the schema, plugin, action, approval_mode or
created_by), and the verifier takes publicKey from the request, so it is an integrity check and not
publisher authentication. See REST API.
- A bundled definition is tombstoned by the delete, so the bundled reseed does not bring the old one back.
Any public import skips the tombstone, whether signed or unsigned under
--allow-unsigned-definitions(both run as a non-seed insert), so the replacement lands and later boots leave it alone. The tombstone is permanent: a replaced bundled definition never receives later bundled corrections from the reseed. - The delete removes only the definition row. A schedule that references the id has its occurrences skipped
and audited as
definition_unknownuntil the id exists again. A policy does not name the instruction; its fragment does (check_instruction,fix_instructionandpost_check_instruction, which falls back tocheck_instruction). Check, fix and verify dispatches that name a missing id logunknown check/fix instructionand send nothing, a remediation then reportsfix dispatch failed (unknown instruction or no agents),POST /api/workflows/{id}/executefor a workflow with a step that names it answers400(references unknown instruction), and a pending approval keeps itsdefinition_id(no foreign key was found). Check policy fragments, stored workflows and pending approvals for the id before deleting. Other references were not checked. - Between steps 4 and 5 the definition does not exist, and a refused import leaves it deleted. Have the corrected (and, unless the unsigned flag is set, signed) document ready and rehearsed before step 4, and do both in one maintenance window.
6. Result Schema and Aggregation
Result schemas declare typed columns for structured output. These types enable downstream consumption by ClickHouse, Splunk, CSV export, and the dashboard aggregation engine.
Result Column Types
| Type | Description | Example Value |
|---|---|---|
bool | Boolean | true, false |
int32 | 32-bit signed integer | 4592 |
int64 | 64-bit signed integer | 1710700800000 |
string | UTF-8 text | "running" |
datetime | ISO 8601 timestamp | "2026-03-17T18:20:00Z" |
guid | UUID / GUID | "550e8400-e29b-41d4-..." |
clob | Character large object | Multi-line log output, stack traces |
The clob type indicates a potentially large text field. The server may apply configurable truncation to limit storage.
Server-Side Aggregation
The ResponseStore supports aggregation across agent responses using GROUP BY and aggregate functions.
result:
columns:
- name: serviceName
type: string
- name: state
type: string
- name: pid
type: int32
aggregation:
groupBy: [state]
operations: [count, min, max, sum, avg]
| Operation | Description |
|---|---|
count | Number of rows per group. |
sum | Sum of numeric column values. |
avg | Average of numeric column values. |
min | Minimum value per group. |
max | Maximum value per group. |
7. Approval Workflows
Yuzu supports three approval modes that control whether an execution requires human sign-off before dispatch.
Approval Modes
| Mode | Behavior |
|---|---|
auto | No approval required. Execution dispatches immediately. |
role-gated | Non-admin users must submit for approval. Admins and users with approveRoles can execute directly. |
always | Every execution requires explicit approval, regardless of the user’s role. |
Rules
- Submitter cannot approve. The account that submits an execution request cannot be the one to approve it. A different account with the appropriate role must review it (the check compares account names; see the known limitation under Who can approve).
- Comments are supported. Both approve and reject actions accept an optional comment for the audit trail.
- Pending count. The API provides a pending approval count for dashboard badges and automation polling.
YAML Configuration
spec:
approval:
mode: role-gated # auto | role-gated | always
Executor-Side Behavior
When a user (or MCP tool) executes an instruction that requires approval:
- HTTP 202 response. The server returns
{"status": "pending_approval", "approval_id": "...", "definition_id": "..."}instead of dispatching immediately. Nothing runs yet: the request waits as a pending approval ticket. Approving a ticket created this way records the decision but does not yet dispatch the instruction, because an instruction-origin ticket has no redemption path (tracked in #6064; see Upgrading). An MCP client is different: it re-calls the same tool with theapproval_idonce the ticket is approved, and that call runs it. - Dashboard toast notification. A toast appears in the web dashboard confirming the request was submitted for approval. The toast includes the approval ID for reference.
- Approvals tab or page. The pending request is visible in the Approvals tab of the dashboard and on the Approvals page (
/approvals). The executor can check its status there at any time. Once an approver approves or rejects the request, the status updates in the tab (a rejected request is never run; an approved instruction-origin ticket is recorded but not dispatched, as in step 1). On the Approvals page, Approve and Reject open a dialog with an optional reviewer comment (capped at 4000 bytes); the sidebar pending-approvals badge refreshes after each decision.
The submitting account cannot approve its own request. A different account with the appropriate role must review it (the check compares account names; see the known limitation under Who can approve).
Who can approve
Approving or rejecting a request is an act of human review, so the server asks for more than the
Approval:Approve permission. The same rules apply to the dashboard Approvals page, which posts to
the same route, and to POST /api/approvals/{id}/approve and POST /api/approvals/{id}/reject:
-
An interactive browser session. The reviewer must be signed in through the sign-in page with a local account or OIDC single sign-on, and the request must carry that session cookie. An API token or an MCP token is refused with
403and an A4 error envelope, however much it is permitted to do elsewhere. -
An enrolled MFA factor and a fresh proof (#6079). Approve and reject are covered by the central step-up check that token creation and role changes use, but that check passes an account that has no factor to ask for, and a disabled window switches it off. The server therefore also requires the reviewer’s own session to show a second-factor proof made within the approver window (the window is
--mfa-step-up-window-secswhen that is between 1 and 300, and 300 when it is0, negative or above 300, so 300 seconds is a ceiling and a lower setting shortens it; it does not depend on--mfa-enforcement; the age is measured on the database clock against the proof stored with your session, so a paused virtual machine or a resumed host cannot extend it). What counts depends on how you signed in:- Local account. The account must have an enrolled TOTP factor, or the decision is refused with
403andapprover_assurance_required:mfa_not_enrolled. If the proof is older than the window the request returns401withmeta.mfa_step_up_required: true; the dashboard prompts for the code inline, and a script posts the code to/login/mfa/stepupand retries (see Step-up on high-risk surfaces). A proof made before the account’s current enrolment does not count: if MFA was disabled and enrolled again, enter a fresh code. If the account itself has been deleted or deactivated while you were signed in, the decision is refused: with step-up on (the default) the central check answers first with401MFA step-up required, and403approver_assurance_required:account_unavailableappears only with--mfa-step-up-window-secs=0(or when the account is removed in the middle of the request). Either way, sign in again or contact an administrator. - OIDC single sign-on. There is no local factor to check. The sign-in must have carried an MFA
method claim (
amr) from your identity provider, on an ID token issued within the approver window (at most 300 seconds, and shorter if--mfa-step-up-window-secsis lower). “Fresh” here means the provider issued an ID token with an MFAamrwithin the window; the MFA itself may predate the token, because the provider can reuse its own session. You are not asked for a second factor again when you approve; real re-authentication at approval time is a planned follow-up. A sign-in with no MFAamr(or an ID token with no usableiat) is refused with403andapprover_assurance_required:mfa_proof_missing; sign in again through your identity provider with MFA, or ask an administrator to have the provider require MFA for approvers and report it inamr. If the identity provider re-authenticates you silently without asserting an MFAamr, the retry returns403mfa_proof_missingagain; complete provider-side MFA (for example by signing out of the provider first) and then retry. Real re-authentication at approval time is a planned follow-up. With step-up on and--mfa-enforcementprotecting your role (required, oradmin-onlyfor an administrator), a sign-in with no MFAamris answered first by the central step-up check with a401that sends you back to your identity provider, so a provider that never asserts an MFAamrloops instead of showingmfa_proof_missing. If the replica that took your sign-in ran ahead of the database clock, so that your stored proof is dated after the database clock, you are refused with503(the approver's identity-provider MFA proof is dated after the database clock, no retry hint) and signing in again does not help: an administrator must correct clock synchronisation between the Yuzu server hosts and the database host (the dashboard shows you the message and the remediation together; see OIDC approvers refused with a clock-skew 503). An older proof is a401whosechallenge_urlis/auth/oidc/start; the Approvals dialog follows that challenge by sending the browser through your identity provider, which abandons the decision you were making, so reopen the request and decide it again after you return. - SAML. Refused with
403(#4935). With step-up on (the default) the central check’s generic403“MFA step-up is not available for SAML sessions in this release” answers first; the typedapprover_assurance_required:saml_mfa_unsupportedappears only with--mfa-step-up-window-secs=0.
On a default install (RBAC enforcement off,
--mfa-enforcement=optional) the only approvers are administrators, and an administrator who has not enrolled a factor in Settings is refusedmfa_not_enrolled, so enrol each administrator before the first approval is needed.A non-admin approver cannot enrol itself in the default modes. Local enrolment happens at sign-in only for accounts that
--mfa-enforcementprotects (optionalprotects nobody;admin-onlyprotects administrators), and the enrolment routes in Settings are administrator-only. A non-admin who holdsApproval:Approve(the seededOperatorrole does) is therefore, with RBAC enforcement on, refusedmfa_not_enrolleduntil an administrator enrols them (by a temporary enrolment sign-in window, or by promoting the account to administrator just long enough to enrol and then demoting it); see “Enrolling a non-admin approver” in the recovery runbook, which also lists each path’s consequences. Make sure at least one enrolled, authorised approver exists who is not the submitter of each request, or nothing can be decided. Break-glass accounts are enrolled by validation and get no exemption. With RBAC enforcement off (the shipped default) the legacy gate requires theadminrole for approving, so a non-admin is refused403“admin role required” before the MFA check and enrolling them achieves nothing until RBAC enforcement is turned on. Self-service enrolment for non-admins is a planned follow-up. - Local account. The account must have an enrolled TOTP factor, or the decision is refused with
-
A human principal. An engine principal is never admitted.
-
Not the submitter. Unchanged: the reviewer cannot be the person who submitted the request (HTTP 400, audited as denied). Known limitation: one person with two identities counts as two reviewers. The check compares the reviewer’s account name with the submitter’s account name, nothing more. A person who holds both a local account and a single sign-on identity, or two single sign-on identities, is two different names to the server, so that person can submit a request under one identity and approve it under the other (each identity still needs its own enrolled MFA factor and fresh proof). This behaviour predates #6079 and was left out of its scope by the owner; resolving every identity to one person is a planned follow-up. Until then, do not rely on the reviewer check as proof that two different humans were involved where users can hold more than one identity.
-
Not a SAML session. MFA step-up is not available for SAML sessions (#4935), so a user who signed in through SAML is refused with
403whatever--mfa-step-up-window-secsis set to (with step-up on, the central check’s generic403answers first; with it off, the approver rule’ssaml_mfa_unsupported; before #6079, setting it to0admitted a SAML approver; it no longer does). Approvers who use SAML cannot approve until they sign in with a local account or OIDC. A deployment where every approver signs in through SAML has no working approver path until they are given an OIDC provider or a local account. Under--auth-mode=sso-onlythe only local sign-in left is the break-glass account, which is not a substitute. This is a known limitation, tracked in #6098 (the SAML step-up decision is #4935). -
--mfa-step-up-window-secs=0no longer lowers approval assurance. The setting turns the step-up gate off for the other step-up endpoints, but approve and reject keep their enrolled-factor and 300-second proof requirement. A decision made before the upgrade gains no retroactive assurance; see Upgrading for how to review those.
MCP clients cannot approve. MCP callers authenticate with a bearer token and the MCP transport runs no MFA step-up, so the MCP tools
approve_request and reject_request always return a typed refusal, whatever credential the caller used
(a browser cookie session that reaches /mcp/v1/ is refused too). This is an exception
recorded in ADR-1005’s exception ledger (entry 2026-10-18): human review is a human-session act. The tools stay registered, and listing
pending approvals through MCP (list_pending_approvals, get_pending_approval_count) is
unchanged. A supervised-tier agentic worker still gets its ticket from the first call and still
recalls the tool with the approval_id; a person approves the ticket in the dashboard or over REST
with a signed-in session.
Failure behaviour.
- If the server cannot resolve the caller’s session while recording the decision, the request fails
closed with
503and the request stays pending. It is no longer recorded with the reviewerunknown. - The approval attempt is written to the audit log before the decision is applied. If that audit row
cannot be written, the request returns
503and nothing changes (#5823). - An approved request that has not been used within 7 days of its review is refused when it is
consumed, even if no new request has been submitted since to sweep expired tickets. The check
uses database time. The submitter must request approval again. The status shown in the Approvals
tab and page, and returned by the list routes, is only rewritten to
expiredby the next approval submission, so an aged approved request keeps showingapproveduntil then although it can no longer be used (tracked in #6098).
If the dialog says you do not have permission. For an approver refusal (approver_assurance_required:<reason>, whether a class or an MFA refusal) the Approvals dialog shows the refusal’s own remediation text, which names the next step. For any other 403 it shows the generic “You do not have permission” text, and the response’s own message names the cause. The causes, in the order the server checks them: the request carried no same-site
Origin or Referer (for example a reverse proxy rewrites Host and the public address is not
listed in --csrf-trusted-origin; the server-wide same-site check answers this first, before any
step-up); you signed in through SAML while step-up is on (the central step-up check, before the
route handler); then, in the handler, you lack Approval:Approve; your account has no
enrolled MFA factor; your account has been deleted or deactivated; your single sign-on login carried no MFA claim; or you signed in through SAML with step-up switched off (--mfa-step-up-window-secs=0), which the approver rule still refuses. The Approve and Reject buttons are a
render hint only: an unenrolled or SAML session is shown them and the route still refuses it. The Approvals tab on Instructions has no dialog: on a 403 it shows the same remediation text in a toast for an approver refusal and the server’s message for any other 403, “Your session expired; sign in again.” on a plain 401, and the same code prompt
as the Approvals page on a step-up 401, then repeats the decision once after a correct code.
Approval API
Approve and reject need a signed-in session cookie (see Who can approve); a bearer
token is refused. The examples assume a session that has completed MFA step-up. A cookie-session POST must carry an Origin (or Referer) naming the server, as a browser sends; a request with neither, or naming another site, is refused with 403 (--csrf-trusted-origin entries are accepted). The error table is in the REST API reference.
# Check pending approval count
curl -s -b cookies.txt \
http://localhost:8080/api/approvals/pending/count
# Approve an execution request
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/approvals/REQ-001/approve \
-H "Origin: http://localhost:8080" \
-H "Content-Type: application/json" \
-d '{"comment": "Reviewed — safe to proceed."}'
# Reject an execution request
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/approvals/REQ-001/reject \
-H "Origin: http://localhost:8080" \
-H "Content-Type: application/json" \
-d '{"comment": "Not approved for production during change freeze."}'
8. Concurrency Model
Corrected 2026-08-31 (ADR-1007) — only per-device is actually enforced. See
docs/yaml-dsl-spec.md §12 for the full corrected model (mode-by-mode status, why enforcement is
server-side rather than agent-side, the plugin: server-class catalog-definition caveat, what
remains unenforced and why) and docs/Instruction-Engine.md §10 for the implementation summary.
This section previously described a fully-implemented 5-mode system, including a wait queue; none
of that was ever built except the one mode below.
Modes
| Mode | Enforcement | Scope | Status |
|---|---|---|---|
per-device | Server-side | One execution of this definition per device at a time | Enforced (default; 191 real shipped definitions use it). |
per-definition | None | — | Not enforced — used only on catalog-only definitions with no live dispatch path. Do not rely on this for dangerous global operations (schema migration, bulk delete) — nothing prevents concurrent execution. |
per-set | None | — | Not enforced, unspecified — no grouping key defined anywhere. Zero real usage. |
global:<N> | None | — | Not enforced — not used in any shipped definition. |
unlimited | None | No limits | No enforcement needed. |
How Enforcement Works
per-device (the only enforced mode): enforced server-side. At dispatch, the server holds a
claim on (definition_id, agent_id) in a dedicated Postgres table
(execution_tracker.concurrency_claims, race-free via a partial unique index), excludes any agent
already holding an open claim for this definition, and releases the claim when that agent reaches
a terminal status. There is no wait queue — an excluded agent is simply not dispatched to on this
attempt; retry once the in-flight execution completes. Error code 3003 is registered for this
condition but not yet surfaced through the dispatch response. Not covered by this gate: raw MCP/REST
dispatch (no definition_id in scope) and the fleet-broadcast arm of dispatch.
One release exception worth knowing: cancelling an execution does not release its claim
(there is no way to tell the agent to stop, so a cancelled-but-still-running execution’s claim
behaves like a normal in-flight one until a terminal response or the reconciler’s TTL bound releases
it — worst case, one hour). A per-device definition dispatched via a workflow step (as opposed
to a direct execute call or a schedule) releases and renews the same way as any other dispatch path
— it just won’t show progress in the executions drawer, since workflow-step dispatch doesn’t yet
correlate a real execution id there.
All other modes are accepted and stored but have no enforcement effect — they behave exactly
like unlimited in practice.
YAML Syntax
spec:
execution:
concurrency: per-device # default — the only mode actually enforced
# concurrency: unlimited # no limits (also the practical effect of any unsupported value)
Stagger
For large-fleet dispatch, stagger introduces a per-agent delay before execution to avoid thundering herd problems. Each agent calculates: total wait = fixedDelaySeconds + random(0, maxDelaySeconds).
spec:
execution:
stagger:
maxDelaySeconds: 60 # each agent delays 0-60s randomly
fixedDelaySeconds: 5 # plus a fixed 5s base delay
If the command has an expiration (expires_at) and the total delay causes it to expire, the agent sends a REJECTED response and skips execution.
The REST API also supports stagger/delay via the stagger and delay integer fields on POST /api/command:
curl -X POST http://server:8080/api/command \
-H "Content-Type: application/json" \
-d '{"plugin":"hardware","action":"cpu-info","stagger":30,"delay":5}'
9. Scheduling
The ScheduleEngine supports recurring instruction executions with four frequency types. The scope expression is evaluated at dispatch time, so newly enrolled devices are automatically included.
Frequency Types
| Type | Fields Used | Description |
|---|---|---|
daily | time_of_day | Runs once per day at the specified time. |
weekly | time_of_day, day_of_week | Runs once per week. day_of_week: 0=Sunday through 6=Saturday. |
monthly | time_of_day, day_of_month | Runs once per month. day_of_month: 1-28 (no 29/30/31 to avoid month-length issues). |
interval | interval_minutes | Runs every N minutes. Minimum: 1 minute; maximum: 35791394. |
Execution semantics
A server-side poller checks for due schedules every 30 seconds, so a fire can
land up to ~30 seconds after its due time — but firing is now two stages, not
one. At fire time the poller creates a tracked execution (attributed to the
schedule’s creator, appearing immediately in the Executions history) and
commits a durable occurrence to the command outbox, emitting an
instruction.schedule_fired audit event with result queued. A leader-gated
delivery loop then drains the outbox and performs the actual dispatch, within
~5 seconds of enqueue — so up to ~35 seconds total from due time to dispatch.
Delivery emits its own command.outbox_delivered audit event recording the
real send outcome: success (dispatched, with the agents-reached count),
failure (no agents currently in scope — recorded and skipped, not retried;
or a malformed occurrence), or denied (authority was revoked between
enqueue and delivery). The execution row’s targeted-agent count fills in at
delivery, once the real outcome is known. A systemic delivery-gate failure
(for example, containment status unreadable) is treated as transient: the
same occurrence is retried with back-off rather than failed outright.
Approval-gated runs (the schedule’s requires_approval flag, or a definition
whose approvalMode is not auto — there is no operator session on the
scheduled path, so role-gated also requires a ticket) submit one approval
request per occurrence and hold at the due time. Approving the ticket fires
the run within one poller cycle; rejecting it skips that occurrence, and the
next occurrence submits a fresh request. Approvals follow the standard
four-eyes rule, so the schedule’s creator cannot approve their own scheduled
runs under the same account name (the check compares account names, not
people; see the known limitation under Who can approve). Poller health is observable via the yuzu_schedule_fires_total,
yuzu_schedule_fire_failures_total, yuzu_schedule_approvals_submitted_total
and yuzu_schedule_tick_errors_total counters.
Schedule Properties
| Field | Type | Description |
|---|---|---|
name | string | Human-readable schedule name. |
definition_id | string | The instruction definition to execute. |
frequency_type | string | daily, weekly, monthly, or interval. |
interval_minutes | int | Minutes between executions (for interval type), 1-35791394. |
time_of_day | string | HH:MM format (for daily, weekly, monthly). |
day_of_week | int | 0-6 (for weekly). |
day_of_month | int | 1-28 (for monthly). |
scope_expression | string | Scope DSL expression evaluated at dispatch time. |
requires_approval | bool | If true, each scheduled run goes through the approval workflow. |
enabled | bool | Toggle the schedule on or off without deleting it. |
Schedule API
# Create a schedule — daily service check at 06:00
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/schedules \
-H "Content-Type: application/json" \
-d '{
"name": "Daily Service Health Check",
"definition_id": "crossplatform.service.inspect",
"frequency_type": "daily",
"time_of_day": "06:00",
"scope_expression": "ostype == \"windows\" AND tag:environment == \"Production\"",
"requires_approval": false,
"enabled": true
}'
# Create a schedule — every 30 minutes
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/schedules \
-H "Content-Type: application/json" \
-d '{
"name": "Process inventory sweep",
"definition_id": "crossplatform.process.list",
"frequency_type": "interval",
"interval_minutes": 30,
"scope_expression": "ostype IN [\"windows\", \"linux\"]",
"enabled": true
}'
# Enable or disable a schedule
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/schedules/SCHED-001/enable \
-H "Content-Type: application/json" \
-d '{"enabled": false}'
# Delete a schedule
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X DELETE http://localhost:8080/api/schedules/SCHED-001
Execution Tracking for Schedules
Each scheduled dispatch creates a normal Execution record. The schedule tracks:
next_execution_at— when the next run is due.last_executed_at— when the last run fired.execution_count— total number of completed runs.
10. Execution Lifecycle
States
An execution progresses through these states:
pending → dispatching → gathering → completed
→ failed
→ cancelled
Per-Agent Tracking
Each agent targeted by an execution has its own status record:
| Field | Description |
|---|---|
agent_id | The targeted agent. |
status | pending, dispatched, running, success, failure, timeout, rejected. |
dispatched_at | When the command was sent to the agent. |
first_response_at | When the agent first responded (streaming). |
completed_at | When the agent finished. |
exit_code | Plugin exit code. |
error_detail | Error message if the agent reported failure. |
Aggregate Counters
The execution record maintains real-time counters:
| Counter | Description |
|---|---|
agents_targeted | Total agents in scope at dispatch time. |
agents_responded | Agents that have sent at least one response. |
agents_success | Agents that completed successfully. |
agents_failure | Agents that reported failure. |
progress_pct | (agents_responded / agents_targeted) * 100. |
Rerun
Rerun creates a new execution linked to the original via rerun_of. Two modes:
# Rerun all agents
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/executions/EXEC-001/rerun \
-H "Content-Type: application/json" \
-d '{"scope": "all"}'
# Rerun only failed agents
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/executions/EXEC-001/rerun \
-H "Content-Type: application/json" \
-d '{"scope": "failed_only"}'
The scope field controls which agents are included. Set "scope": "failed_only" to target only agents that reported failure; any other value (or omitting the field) reruns all agents.
Cancel
Cancellation records the requesting user for audit:
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/executions/EXEC-001/cancel
Parent/Child Executions
Executions can form hierarchies using the parent_id field. This supports:
- Follow-up instructions — a remediation execution that references the original diagnostic.
- Multi-step workflows — a chain of executions where each step references the previous.
11. Error Codes
Errors are categorized into four domains with non-overlapping numeric ranges.
1xxx — Plugin Errors
| Code | Name | Description | Retryable |
|---|---|---|---|
| 1001 | PLUGIN_ACTION_NOT_FOUND | Plugin does not support the requested action | No |
| 1002 | PLUGIN_PARAM_INVALID | Parameter validation failed | No |
| 1003 | PLUGIN_PERMISSION_DENIED | OS-level permission denied (e.g. non-admin) | No |
| 1004 | PLUGIN_RESOURCE_MISSING | Target resource does not exist | No |
| 1005 | PLUGIN_OPERATION_FAILED | Action executed but failed | Transient |
| 1006 | PLUGIN_CRASH | Plugin segfaulted or threw unhandled exception | No |
| 1007 | PLUGIN_TIMEOUT | Plugin did not complete within gather TTL | Once |
2xxx — Transport Errors
| Code | Name | Description | Retryable |
|---|---|---|---|
| 2001 | TRANSPORT_DISCONNECTED | Agent lost connection during execution | Always |
| 2002 | TRANSPORT_STREAM_ERROR | gRPC stream broken mid-response | Always |
| 2003 | TRANSPORT_RESPONSE_TOO_LARGE | Response exceeds max message size | No |
3xxx — Orchestration Errors
| Code | Name | Description | Retryable |
|---|---|---|---|
| 3001 | DefinitionNotFound | The referenced InstructionDefinition does not exist | No |
| 3002 | ApprovalRequired | Execution blocked pending approval | Awaits human |
| 3003 | ConcurrencyBlocked | Registered for per-device claim exclusion; not yet surfaced through the dispatch response (ADR-1007) | After slot frees |
| 3004 | ScopeEmpty | The resolved target scope matched no agents | No |
| 3005 | ScheduleExpired | The schedule’s execution window has passed | No |
Names above are the taxonomy’s actual registered names (server/core/src/error_codes.cpp) — this
row set was corrected from a stale naming scheme this table had carried (ORCH_* prefixes that
never matched the code). Like 3003, none of 3001/3002/3004/3005 currently has a production call
site that emits it (error_codes.cpp’s registration is not the same as being wired to a real
error path yet) — the retry-semantics column above states the DESIGNED behavior per the taxonomy
entry, not an observed one.
4xxx — Agent Errors
| Code | Name | Description | Retryable |
|---|---|---|---|
| 4001 | AGENT_PLUGIN_NOT_LOADED | Required plugin not available on agent | No |
| 4002 | AGENT_SHUTTING_DOWN | Agent is in shutdown sequence | On reconnect |
| 4003 | AGENT_VERSION_INCOMPATIBLE | Agent version below minAgentVersion | No |
Retry Semantics
| Category | Default Retry | Max Attempts | Backoff |
|---|---|---|---|
| Transport (2xxx) | Always | 3 | Exponential (1s, 2s, 4s) |
| Transient plugin (1005, 1007) | If retryable: true | 2 | Linear (5s) |
| Deterministic plugin (1001-1004, 1006) | Never | 0 | — |
| Agent (4001-4003) | Never (except 4002) | 1 | — |
| Orchestration (3001-3005) | Per-code as noted | — | — |
12. REST API Reference
All API endpoints require session-cookie authentication. Obtain a session by posting to /login first.
There are two API surface areas with different response envelopes:
/api/* endpoints (instruction engine, registered in instruction_routes.cpp/execution_routes.cpp and server.cpp) use domain-keyed responses:
{
"definitions": [ ... ],
"count": 42
}
The key name varies by resource type ("definitions", "executions", "schedules", "approvals", "sets", "agents", "children"). Error responses use {"error": "message"}.
/api/v1/* endpoints (versioned REST API, registered in rest_api_v1.cpp) use a standard envelope with metadata:
{
"data": { ... },
"meta": { "api_version": "v1" }
}
List endpoints on /api/v1/* include pagination:
{
"data": [ ... ],
"pagination": { "total": 42, "start": 0, "page_size": 50 },
"meta": { "api_version": "v1" }
}
Error responses on /api/v1/*:
{
"error": "descriptive message",
"code": 1002,
"meta": { "api_version": "v1" }
}
Definitions
List definitions
GET /api/instructions
Query parameters: name, plugin, type, set_id, enabled_only, limit.
# List all definitions
curl -s -b cookies.txt http://localhost:8080/api/instructions
# Filter by plugin
curl -s -b cookies.txt "http://localhost:8080/api/instructions?plugin=services"
# Filter by type
curl -s -b cookies.txt "http://localhost:8080/api/instructions?type=question"
Get a single definition
GET /api/instructions/{id}
curl -s -b cookies.txt \
http://localhost:8080/api/instructions/crossplatform.service.inspect
Create a definition (JSON)
POST /api/instructions
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/instructions \
-H "Content-Type: application/json" \
-d '{
"id": "crossplatform.service.inspect",
"name": "Inspect Service Status",
"version": "1.2.0",
"type": "question",
"plugin": "services",
"action": "inspect",
"description": "Returns current state of a named service.",
"platforms": "windows,linux,darwin",
"approval_mode": "auto",
"concurrency_mode": "per-device",
"parameter_schema": "{\"type\":\"object\",\"required\":[\"serviceName\"],\"properties\":{\"serviceName\":{\"type\":\"string\"}}}",
"result_schema": "[{\"name\":\"state\",\"type\":\"string\"},{\"name\":\"pid\",\"type\":\"int32\"}]"
}'
Update a definition
PUT /api/instructions/{id}
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X PUT http://localhost:8080/api/instructions/crossplatform.service.inspect \
-H "Content-Type: application/json" \
-d '{
"name": "Inspect Service Status",
"version": "1.3.0",
"description": "Updated description with new fields."
}'
Delete a definition
DELETE /api/instructions/{id}
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X DELETE http://localhost:8080/api/instructions/crossplatform.service.inspect
Save from YAML
POST /api/instructions/yaml
Accepts Content-Type: application/x-yaml. Parses the YAML, extracts metadata and spec fields, and creates or updates the definition. The verbatim YAML is stored as yaml_source.
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/instructions/yaml \
-H "Content-Type: application/x-yaml" \
--data-binary @my-definition.yaml
Validate YAML (dry run)
POST /api/instructions/validate-yaml
Returns validation errors without saving.
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/instructions/validate-yaml \
-H "Content-Type: application/x-yaml" \
--data-binary @my-definition.yaml
Import from JSON
POST /api/instructions/import
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/instructions/import \
-H "Content-Type: application/json" \
-d @exported-definition.json
Export to JSON
GET /api/instructions/{id}/export
curl -s -b cookies.txt \
http://localhost:8080/api/instructions/crossplatform.service.inspect/export \
-o exported-definition.json
Execute an instruction definition
POST /api/instructions/{id}/execute
Dispatches the instruction definition to agents. Requires Execution:Execute permission.
Request body:
{
"agent_ids": ["agent-uuid-1"],
"params": {"path": "C:\\Windows\\System32\\notepad.exe"}
}
agent_ids— optional array of specific agent IDs to target.scope— optional scope expression (e.g.,group:servers,os:windows AND tag:prod), or__all__for every enrolled agent. Omit bothscopeandagent_idsto broadcast. A supplied empty string, a non-stringscope, an emptyagent_ids, a non-arrayagent_ids, or a non-string entry is refused with400rather than widened to the whole fleet (#2500) — a target the caller named that resolves to nothing is an error, not a request for everything.params— key-value parameters to pass to the plugin action. Keys should match the definition’sparameter_schema. This route checks them against it (see Parameter validation).
Response (200):
{
"command_id": "filesystem-a1b2c3",
"execution_id": "exec-d4e5f6",
"agents_reached": 3,
"definition_id": "filesystem.exists"
}
command_id— legacy correlation token; retained on the response body for future use. To poll individual agent results, callquery_responseswith theexecution_id(exact — just this dispatch) orinstruction_id(all responses to that definition);command_iditself is not aquery_responsesfilter.execution_id— per-run identifier required byGET /api/v1/events(live JSON SSE stream) andGET /api/v1/executions/{id}(final state lookup). Agentic workers should subscribe to the events stream with?execution_id=<execution_id>immediately after dispatch to watch progress in real time. Empty string if the server was started without anExecutionTracker(test harnesses and stripped-down deployments only — production always has one).agents_reached— number of agents the command was actually sent to (may be smaller than the targeted set if some are offline).definition_id— echoes the path parameter.
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/instructions/filesystem.exists/execute \
-H "Content-Type: application/json" \
-d '{"params":{"path":"C:\\Windows"}}'
Approval gate: The response varies based on the definition’s
approval_mode. Definitions withapproval_mode: autoreturn HTTP 200 with an immediate execution result. Definitions withapproval_mode: role-gatedoralwaysreturn HTTP 202 with apending_approvalstatus and anapproval_idwhen the caller requires approval. See Section 7 for details.
List definitions (v1 endpoint)
GET /api/v1/definitions
The versioned v1 endpoint. Returns all definitions with the {"data": [...], "pagination": {...}, "meta": {...}} envelope. Note: this endpoint does not currently accept query parameters for filtering — it returns all definitions. Use /api/instructions with query parameters (name, plugin, type, set_id, enabled_only, limit) if filtering is needed.
curl -s -b cookies.txt http://localhost:8080/api/v1/definitions
Instruction Sets
List instruction sets
GET /api/instruction-sets
curl -s -b cookies.txt http://localhost:8080/api/instruction-sets
Create a set
POST /api/instruction-sets
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/instruction-sets \
-H "Content-Type: application/json" \
-d '{
"name": "Service Operations",
"description": "All service management definitions"
}'
Delete a set
DELETE /api/instruction-sets/{id}
Cascade-deletes all definitions in the set.
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X DELETE http://localhost:8080/api/instruction-sets/SET-001
Schedules
List schedules
GET /api/schedules
Query parameters: definition_id, enabled_only.
# List all schedules
curl -s -b cookies.txt http://localhost:8080/api/schedules
# List only enabled schedules
curl -s -b cookies.txt "http://localhost:8080/api/schedules?enabled_only=true"
The dashboard’s Schedules tab (see Dashboard UI below) is backed by the
versioned GET /api/v1/schedules twin, not the legacy endpoint above. If the schedule store is
temporarily unreachable, the tab shows “Schedule list temporarily unavailable — retry shortly”
instead of an empty list. Schedule reads (dashboard, REST v1, and MCP list_schedules alike) are
capped at 100 rows; if a fleet has more schedules than that, the tab shows a partial-list notice
above the table, and GET /api/v1/schedules’s JSON response carries result_truncated_by_cap: true
(see REST API) — the two are the same underlying signal.
Create a schedule
POST /api/schedules
See Section 9 for the full field reference and examples.
Delete a schedule
DELETE /api/schedules/{id}
curl -s -b cookies.txt -X DELETE http://localhost:8080/api/schedules/SCHED-001 -H "Origin: http://localhost:8080"
Enable/disable a schedule
POST /api/schedules/{id}/enable
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/schedules/SCHED-001/enable \
-H "Content-Type: application/json" \
-d '{"enabled": true}'
Approvals
List approvals
GET /api/approvals
Query parameters: status, submitted_by.
# List all approvals
curl -s -b cookies.txt http://localhost:8080/api/approvals
# List pending approvals
curl -s -b cookies.txt "http://localhost:8080/api/approvals?status=pending"
Pending approval count
GET /api/approvals/pending/count
curl -s -b cookies.txt http://localhost:8080/api/approvals/pending/count
Approve
POST /api/approvals/{id}/approve
Approve and reject need a signed-in session cookie, an enrolled MFA factor with a fresh proof (#6079) and a
same-site Origin/Referer; an API or MCP token is refused
with 403. See
Who can approve and the error table in the REST API reference.
The reviewer’s account must differ from the submitter’s (account names are compared, so
one person holding two identities is not caught; see Who can approve): self-approval
with the same account is rejected with HTTP 400 ("reviewer cannot be the same as the submitter"),
and the denied attempt is recorded in the audit log. The comment is capped at
4,000 bytes (HTTP 400 beyond that).
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/approvals/REQ-001/approve \
-H "Origin: http://localhost:8080" \
-H "Content-Type: application/json" \
-d '{"comment": "Approved."}'
Reject
POST /api/approvals/{id}/reject
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/approvals/REQ-001/reject \
-H "Origin: http://localhost:8080" \
-H "Content-Type: application/json" \
-d '{"comment": "Denied — change freeze in effect."}'
Executions
All routes below are management-group confined (#3789) — see rest-api.md’s “Executions” section
for the per-route permission and confinement/404 behavior.
List executions
GET /api/executions
Query parameters: definition_id, status, limit.
# List all executions
curl -s -b cookies.txt http://localhost:8080/api/executions
# Filter by definition
curl -s -b cookies.txt "http://localhost:8080/api/executions?definition_id=crossplatform.service.inspect"
# Filter by status
curl -s -b cookies.txt "http://localhost:8080/api/executions?status=completed"
Get a single execution
GET /api/executions/{id}
curl -s -b cookies.txt http://localhost:8080/api/executions/EXEC-001
Get execution summary
GET /api/executions/{id}/summary
Returns aggregate counters including progress_pct.
curl -s -b cookies.txt http://localhost:8080/api/executions/EXEC-001/summary
Get per-agent statuses
GET /api/executions/{id}/agents
curl -s -b cookies.txt http://localhost:8080/api/executions/EXEC-001/agents
Get child executions
GET /api/executions/{id}/children
curl -s -b cookies.txt http://localhost:8080/api/executions/EXEC-001/children
Rerun
POST /api/executions/{id}/rerun
# Rerun failed agents only
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/executions/EXEC-001/rerun \
-H "Content-Type: application/json" \
-d '{"scope": "failed_only"}'
Cancel
POST /api/executions/{id}/cancel
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST http://localhost:8080/api/executions/EXEC-001/cancel
Cancelling an execution that already finished (succeeded, completed or cancelled) answers 409 and changes nothing; the Executions page offers Cancel only for executions that are still running. Per-device results and the live view are on the page’s detail pane.
13. Dashboard UI
The Instruction Management page is accessible from the main dashboard. It uses HTMX to load content fragments without full page reloads.
Tabs
| Tab | Description |
|---|---|
| Definitions | Browse, search, create, edit, and delete instruction definitions. Supports both form mode and CodeMirror YAML editor. |
| Executions | Execute instructions and view results. The top section provides an execution form: select a definition from the dropdown (grouped by plugin), fill in parameters (auto-populated from the definition’s schema), choose a scope (all agents, a group, or an individual agent), and click Execute. Below the form, the execution history table shows each run with a 4-segment status sparkbar (succeeded / failed / running / pending — length encodes count, hue encodes status), the resolved definition name, the wall-clock dispatch time in the operator’s local timezone (HH:MM:SS.mmm <TZ>, e.g. 12:22:33.251 BST; full ISO-8601 UTC on hover), and — on failed rows — an 80-character preview of the most recent agent error. The “Fan-out” cell shows succeeded / failed / targeted counts that update in real time as agents respond. Click any row to expand an inline drawer that live-updates via SSE as responses arrive — no page reload required. The drawer shows a KPI strip (Total / Succeeded / Failed / p50 / p95 duration), a small-multiples agent grid colored by status (decile-bucketed when the execution targets more than 1024 agents), a per-agent table sorted failed-first with inline duration bars, and a responses table with a Time column showing the server-side response arrival time at millisecond precision (same wall-clock format as above). The drawer is keyboard-reachable via Tab and Enter/Space. The Executions page (/executions) takes ?definition_id=<id> and ?status=<s> to pre-filter the list and ?id=<execution> to open one. The drawer’s responses table, its live updates and the page detail omit the rows of Forensics-tier plugins (app_usage, browser_inventory, execution_artifacts, privacy_permissions, remote_access_audit) for a caller without Forensics:Read, with no marker, and the failure preview of such a command is a short machine token; see “Forensics-tier rows” in rest-api.md. |
| Schedules | Create, enable/disable, and delete recurring schedules. Shows next and last execution times. |
| Approvals | Review pending approval requests. Approve or reject with comments. The sidebar’s Approvals entry shows the pending count. A submitting account cannot review its own request (account names are compared; see Who can approve). A pending row you submitted shows a “You submitted this” hint saying another reviewer must approve, instead of buttons, and any backend denial (self-review, already-reviewed) surfaces as an error toast. |
Executions and Approvals pages. /executions and /approvals are read-only pages in the sidebar under Instructions: Executions lists the dispatch history with the same status counters as the tab, and Approvals lists the approval queue, pending first. Both honour your access scope and show a note, not an empty list, when a store cannot be read. Executing stays on the Instructions tab. On /approvals, pending rows carry Approve and Reject buttons (dialog with an optional comment). On /executions, selecting a row shows per-device results; a running execution updates live over SSE (a chip shows the stream state); and Cancel execution (dialog, shown when you may cancel and the execution is not finished) cancels it. Fleet-wide figures (today, all time, device success rate, average duration) appear for unconfined operators only and are hidden when the store cannot be read.
Workflows page. /workflows is a page in the sidebar under Instructions (its workflow actions are described below): the workflow definitions (name, description, step count; selecting one lists its steps with each step’s failure policy) and the recurring schedules. Schedules run a single instruction on the cadence the scheduler really applies (once, every N minutes, daily, weekly or monthly; time of day, weekday and day of month are stored but not used) and are not linked to workflows. A schedule you did not create shows only whether it targets all devices or a subset.
The definitions table also shows each workflow’s Last run and Result (the newest run you may see: “Not run” when there is none, “None visible” when runs exist only on devices outside your scope, “Unknown” when the list of newest runs was cut off before it reached that workflow). Selecting a workflow loads its latest run in the Latest run panel (status, start, duration, device count and one row per step) and the Recent runs disclosure lists its ten newest runs; selecting one loads it. A step shown as Dispatched means the command was sent: it says nothing about the result on a device, and for a caller confined to a device scope it reads Dispatched (run-wide) because the send covered the run’s whole target list. A skipped step says why only for the three reasons the engine writes (condition not met, previous step failed, workflow aborted); a failed step shows its error text when the engine stored one, otherwise “No detail.”. A run is visible to you only if at least one of its target devices is in your scope; a run you may not see and a run that does not exist look the same.
Workflow actions. New workflow (shown when you hold Workflow:Write) opens a dialog for the workflow’s YAML; the server’s message is shown if the YAML is refused, and nothing runs until you run it. Each row’s Run now (needs Workflow:Execute) opens a device checklist built from the connected devices you may see (filter box, the first 200 matches shown, at most 500 selected); without permission to list devices it falls back to a box of device ids, one per line. Each step is sent to every selected device in order; a step whose instruction needs approval is refused, and device results are not collected on this page. The server runs the whole workflow before it answers, so the page waits 8 seconds, then says the run may keep running on the server, disables that workflow’s Run now and checks its newest run every 5 seconds for up to 10 minutes. Check Recent runs before running it again. Delete in the Steps panel (needs Workflow:Delete) retires the workflow after a confirmation; run history is kept and there is no undelete in the console. A control you may not use is shown disabled with the permission it needs. Instruction names appear on step rows only if you also hold InstructionDefinition:Read; otherwise the step’s label or instruction id is shown. Schedule actions. Each schedule row has Enable/Disable and Delete, enabled only on schedules you created (a schedule with no recorded creator is nobody’s, so both stay disabled). Enable needs Schedule:Write and Execution:Execute and asks for confirmation because the schedule then runs without anyone present; Disable needs Schedule:Write and is one click; Delete needs Schedule:Delete and confirms first. After a change the list is re-read and the stored state shown. New schedule (needs Schedule:Write and Execution:Execute; the instruction list needs InstructionDefinition:Read) takes a name, an enabled instruction, a cadence (once runs at the next scheduler tick, about now; the scheduler ignores time of day), targets, whether each run needs approval and parameters as JSON. Leaving targets empty needs an explicit acknowledgement that it reaches every device. A final summary shows the instruction’s approval mode before the schedule is created enabled; a run can still be refused when it fires. Not verified by running a workflow against a live fleet: that the server keeps running a workflow after the page stops waiting.
Who sees what: Administrator, Operator, PlatformEngineer, Viewer and ITServiceOwner hold Workflow:Read and see all definitions; runs follow the fleet-read scope of the account. A caller scoped only to management groups is refused the definitions and schedules panels (the existing global-grant rule) but sees runs that include a device in scope. ITServiceOwner can create, enable and delete its own schedules elsewhere but has no Run, New workflow or Delete workflow control, and a service-scoped token is refused the schedules panel outright.
Operational notes: listing definitions reads each workflow’s steps with one query per row (up to 101 per page load, newest first), so the page asks for 101 and never renders the YAML. Opening the page or refreshing a denied panel writes a denial audit row for each refused panel, so a denied panel is not polled; reading a run’s detail writes workflow_execution.detail.fetch. Not verified by running it: that a run keeps going on the server when the browser disconnects, and the content of the perm_fn denial audit rows for the refused panels.
YAML Editor
The definition editor supports two modes:
- Form mode: Structured inputs with dropdowns for type, platform, concurrency mode, and approval mode. Validation feedback inline.
- Code mode: Full CodeMirror editor with YAML syntax highlighting. Paste or write YAML directly. A “Validate” button checks syntax without saving.
Both modes save through the same /api/instructions/yaml endpoint.
Inline Parameters (CLI-style)
The dashboard instruction bar supports CLI-style inline parameters using key=value syntax:
filesystem exists path=C:\Windows\System32\notepad.exe
registry get_value hive=HKLM key=SOFTWARE\Microsoft\Windows value=ProductName
Syntax rules:
- Tokens containing
=are parsed askey=valueparameters - Tokens without
=form the command text (plugin + action) - Quoted values are supported:
key="value with spaces" - Keys are lowercased; values preserve original case
- Parameters are passed to the plugin as a
map<string, string>
This differs from YAML-parameterised instructions where parameters are defined in the parameter_schema of an InstructionDefinition. Inline parameters are ad-hoc and bypass schema validation — they are sent directly to the plugin action. YAML-defined parameters provide type constraints, descriptions, default values, and validation.
Response Visualization (chart deck)
When the dispatched (plugin, action) reverse-resolves to an enabled InstructionDefinition that declares spec.visualization or spec.visualizations, a chart deck auto-renders above the results table — no operator action required. The dashboard:
- Resolves
(plugin, action)to the first enabled definition with a configured visualization. The reverse-lookup is gated onInstructionDefinition:Readin addition toExecution:Execute; an operator withoutInstructionDefinition:Readsees the dispatch succeed but no chart appears. - Emits an OOB
<div id="chart-deck-host">with a 2-second deferred load trigger so responses have time to arrive. - Fetches
/fragments/results?...&definition_id=<id>which returns one chart-card placeholder per configured chart in a<div class="yuzu-chart-deck">container. - The Yuzu chart adapter (
/static/yuzu-charts.js) — built on Apache ECharts 5 (/static/echarts.min.js) and themed from Yuzu design tokens — populates each placeholder fromGET /api/v1/executions/{id}/visualization?definition_id=<id>&index=<N>.
Limitations operators should expect:
- First-match arbitrariness. When multiple enabled definitions share
(plugin, action)and more than one declares a visualization, the alphabetically-first definition wins. Authors should ensure at most one chart-bearing definition per(plugin, action)— or dispatch via the Instructions tab (which carriesdefinition_idexplicitly). - F5 mid-dispatch. Reloading the dashboard within the 2-second window cancels the deferred chart load. Re-dispatch to recover.
- Row cap. Each chart’s underlying response read is capped at 10 000 rows. The read is also capped at 50 MiB of row payload.
rows_capped: truemeans the 10 000-row cap cut the read; when either the row cap or the 50 MiB cap cut it, the payload also carriesresult_truncated_by_cap: trueandtruncation_cause(row_caporbyte_cap). The dashboard renders the chart from the truncated set and shows a “Partial result” notice in the chart’s card above the chart, naming which cap cut it. The chart is fetched independently of the results table, so the notice appears on every page that shows the chart deck, including the filtered results view. - No definition? No chart. Free-form
(plugin, action)dispatches that don’t correspond to any enabled definition withspec.visualizationproduce only the standard tabular results — the dashboard does not render an empty chart card. - Dashboard YAML editor strips
spec.visualization. The dashboard’s CodeMirror editor (POST /api/instructions/yaml) saves the YAML source verbatim intoyaml_sourcebut its schema-aware field extractor (instruction_yaml, which indexes the id/name/plugin/action/type/description/concurrency/approval columns from both the canonical nested and flat schemas) does not extractspec.visualizationinto the indexedvisualization_speccolumn. Result: editing a chart-bearing definition in the dashboard editor and saving silently disables its chart until the definition is re-imported viaPOST /api/v1/definitions/import(JSON envelope, full visualization extraction) or via a server restart that triggers the bundled-content auto-import. Author chart-bearing definitions throughPOST /api/v1/definitions/importrather than the editor save. Tracked as a known gap pending yaml-cpp Windows MSVC resolution (#625).
See docs/yaml-dsl-spec.md § spec.visualization for the chart configuration schema and docs/user-manual/rest-api.md § Execution Visualization for the underlying REST API.
Worked examples (default-shipped). Six chart-bearing definitions ship in the in-tree content library as the default demo set:
| Definition | Chart |
|---|---|
security.vuln_scan.summary | pie — vulnerabilities by severity |
security.antivirus.defender_status | pie — Defender real-time protection |
security.encryption.state | pie — volume protection (BitLocker / LUKS / FileVault) |
security.firewall.state | column (multi-series) — firewall state per profile |
security.certificates.list | pie — certificates by issuer (top-N + Other) |
device.os_info.os_name | pie — OS distribution across the fleet |
They are bundled as InstructionSet demo.visualization.fleet-posture (content/definitions/visualization_demo_set.yaml) and as ProductPack pack.demo.visualization (content/packs/visualization-demo-pack.yaml) and are auto-imported on server startup — no manual import step is required on a fresh install. The 217 InstructionDefinitions and 10 InstructionSets under content/ are converted to JSON envelopes at build time by server/core/scripts/embed_content.py (PyYAML), embedded in the server binary as kBundledDefinitions / kBundledSets, and upserted on every startup via import_definition_json / create_set. Conflicts on already-existing IDs are silently skipped — operator-customised definitions are never overwritten by the auto-import. Each successful or errored import emits an audit_events.action="content.bundled_import" row with principal=system for SOC 2 traceability.
On upgrade from a release prior to the build-time content embed, definitions an operator previously DELETED will reappear after restart. To permanently suppress a shipped definition, set enabled: false via the dashboard or PATCH /api/v1/definitions/{id} rather than deleting the row.
To force-install on an existing deployment via the operator UI (e.g. for a custom InstructionSet not in the in-tree library), still use the dashboard YAML import view or:
curl -X POST -H "Authorization: Bearer $TOKEN" \
--data-binary @content/packs/visualization-demo-pack.yaml \
https://yuzu.example/api/v1/product-packs
Then dispatch any of the six from the dashboard “Send” panel — the chart deck auto-renders above the results table.
Response Templates (saved views)
A response template is a named bundle of column subset + sort order + filter presets attached to an InstructionDefinition. When the operator opens the execution results view for a definition, the filter bar’s View dropdown lists every available template; selecting one re-renders the table with the chosen columns visible, the chosen sort applied, and any equals-op filters auto-populated.
Synthesised default. Every definition has at least one template — the engine synthesises a __default__ view from spec.result.columns (or, when omitted, from the plugin’s hard-coded column schema) so the dropdown is never empty. The synthesised default lists all columns, applies no sort, and applies no filters — i.e. it matches the legacy “show everything” behaviour.
Authoring. Operators can author additional templates either by including a spec.responseTemplates array in the YAML definition (see docs/yaml-dsl-spec.md § spec.responseTemplates) or via the REST CRUD surface:
| Verb | Path | Purpose |
|---|---|---|
GET | /api/v1/definitions/{id}/response-templates | List, with the synthesised default auto-prepended when no operator default is set |
GET | /api/v1/definitions/{id}/response-templates/{tid} | Fetch one |
POST | /api/v1/definitions/{id}/response-templates | Create (auto-assigns id when absent) |
PUT | /api/v1/definitions/{id}/response-templates/{tid} | Replace in place |
DELETE | /api/v1/definitions/{id}/response-templates/{tid} | Remove. Rejects __default__ |
Reads are gated on InstructionDefinition:Read, mutations on InstructionDefinition:Write.
Limitations operators should expect:
- Equals-op only in dashboard auto-apply. The dashboard’s filter map is exact-match;
contains/starts_with/ends_withfilters in a template are stored and returned by REST but the dashboard only auto-applies theequalsclauses. Operators can still use the column-by-column filter controls to narrow the view further once the template is loaded. - Detail drawer ignores the column subset. The expandable per-row detail drawer always shows every parsed field — hiding values there would defeat its “expand for full record” purpose.
- Dashboard YAML editor strips
spec.responseTemplates. Same caveat asspec.visualization. Author throughPOST /api/v1/definitions/import(JSON envelope) or the REST template endpoints.
14. Planned Features
The following instruction engine capabilities are on the roadmap but not yet implemented.
Workflow Primitives (Phase 7.14)
Conditional logic, iteration, and retry within instruction execution:
if/else— branch execution based on result data from a previous step.foreach— iterate over a list of values (e.g. services, files) and execute a definition for each.retry— automatically retry a failed step with configurable backoff.
These will be expressed as additional YAML DSL constructs within an InstructionDefinition’s spec.workflow block.
Product Packs (Phase 7.9)
Signed distribution bundles containing instruction sets, definitions, policy fragments, and documentation. Product packs already carry Ed25519 signatures: a pack’s signature is verified at install against the key inside the pack, a pack that fails verification is rejected, and an unsigned pack is refused unless the server runs with --allow-unsigned-packs. A trust chain anchored to an organization root key, and staged review before activation, are still planned.