Gate a Dangerous Tool with Guardrails
A guardrail classifies every gated tool call into an action class with deterministic JSON Logic: A (always execute), B (execute if a guard passes), C (human sign-off), D (forbidden).
Build one guardrail over a budget-update tool, dry-run it, drive it from an orchestration tool node through all three outcomes (execute, park for sign-off, tripwire), read the governance trail (approvals, exceptions, audit log), then tighten the whole project with a second guardrail. No AI provider is required.
Prerequisites
- SOAT running locally at
http://localhost:5047(Quick Start); Key Concepts; Configuration. - CLI or SDK.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
export SOAT_BASE_URL=http://localhost:5047
Step 1 — Log in as admin
See Users.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
The project is also the broadest guardrail attach scope (Step 12).
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Guardrails Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Guardrails Demo' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Guardrails Demo"}' | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
Step 3 — Create the tool the guardrail gates
The gate sits at the tool-execution boundary, so the guardrail needs a Tool. A read-only builtin tool named update-budget stands in for the tool that would move money.
- CLI
- SDK
- curl
BUDGET_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "update-budget" \
--type "builtin" \
--description "Stand-in for the sensitive action the guardrail gates" \
--actions '["get-project"]' \
--preset-parameters '{"project_id": "'"$PROJECT_ID"'"}' | jq -r '.id')
echo "BUDGET_TOOL_ID: $BUDGET_TOOL_ID"
const { data: budgetTool } = await adminSoat.tools.createTool({
body: {
project_id: PROJECT_ID,
name: 'update-budget',
type: 'soat',
description: 'Stand-in for the sensitive action the guardrail gates',
actions: ['get-project'],
preset_parameters: { projectId: PROJECT_ID },
},
});
const BUDGET_TOOL_ID = budgetTool.id;
BUDGET_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"update-budget\",\"type\":\"soat\",\"description\":\"Stand-in for the sensitive action the guardrail gates\",\"actions\":[\"get-project\"],\"preset_parameters\":{\"projectId\":\"$PROJECT_ID\"}}" \
| jq -r '.id')
echo "BUDGET_TOOL_ID: $BUDGET_TOOL_ID"
Step 4 — Write the guardrail
A guardrail document has class, guard, and the fail-closed default_class. Here: class B below 500, C at or above, guard requiring the amount under 200. Amounts 150, 900, and 450 exercise every outcome.
- CLI
- SDK
- curl
GUARDRAIL_ID=$(soat create-guardrail \
--project-id "$PROJECT_ID" \
--name "Budget Update Guardrail" \
--description "Autonomous under 200, gated under 500, sign-off above" \
--document '{
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 500] }, "B", "C"] },
"guard": { "<": [{ "var": "args.amount" }, 200] }
}' | jq -r '.id')
echo "GUARDRAIL_ID: $GUARDRAIL_ID"
const { data: guardrail } = await adminSoat.guardrails.createGuardrail({
body: {
project_id: PROJECT_ID,
name: 'Budget Update Guardrail',
description: 'Autonomous under 200, gated under 500, sign-off above',
document: {
default_class: 'C',
class: { if: [{ '<': [{ var: 'args.amount' }, 500] }, 'B', 'C'] },
guard: { '<': [{ var: 'args.amount' }, 200] },
},
},
});
const GUARDRAIL_ID = guardrail.id;
GUARDRAIL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/guardrails" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Budget Update Guardrail\",\"description\":\"Autonomous under 200, gated under 500, sign-off above\",\"document\":{\"default_class\":\"C\",\"class\":{\"if\":[{\"<\":[{\"var\":\"args.amount\"},500]},\"B\",\"C\"]},\"guard\":{\"<\":[{\"var\":\"args.amount\"},200]}}}" \
| jq -r '.id')
echo "GUARDRAIL_ID: $GUARDRAIL_ID"
Validated on write: every var must resolve to the args.*, context.*, or runtime.* namespaces; an out-of-catalog runtime.* key is a 400.
Step 5 — Dry-run every decision before attaching
Dry-run evaluation runs the real pipeline against supplied arguments and returns the record a real call would produce; nothing executes, no approval is filed.
- CLI
- SDK
- curl
soat evaluate-guardrail --guardrail-id "$GUARDRAIL_ID" --tool-id "$BUDGET_TOOL_ID" \
--args '{"amount": 150}' | jq '{class, decision, guard_result, context_snapshot}'
Expected output (under both thresholds):
{
"class": "B",
"decision": "execute",
"guard_result": true,
"context_snapshot": {
"args.amount": 150
}
}
The other two amounts:
soat evaluate-guardrail --guardrail-id "$GUARDRAIL_ID" --tool-id "$BUDGET_TOOL_ID" \
--args '{"amount": 900}' | jq '{class, decision, guard_result}'
soat evaluate-guardrail --guardrail-id "$GUARDRAIL_ID" --tool-id "$BUDGET_TOOL_ID" \
--args '{"amount": 450}' | jq '{class, decision, guard_result}'
900 classifies C (decision: "route_to_approval", guard_result: null: the guard is not consulted for class C). 450 classifies B but fails the < 200 guard: decision: "tripwire".
for (const amount of [150, 900, 450]) {
const { data: evaluation } = await adminSoat.guardrails.evaluateGuardrail({
path: { guardrail_id: GUARDRAIL_ID },
body: { args: { amount }, tool_id: BUDGET_TOOL_ID },
});
console.log(amount, evaluation.class, evaluation.decision, evaluation.guard_result);
}
// 150 B execute true
// 900 C route_to_approval null
// 450 B tripwire false
for amount in 150 900 450; do
curl -s -X POST "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID/evaluate" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"args\":{\"amount\":$amount},\"tool_id\":\"$BUDGET_TOOL_ID\"}" \
| jq '{class, decision, guard_result}'
done
JSON Logic coerces an absent var to a zero-ish value, so { "<": [{ "var": "args.amount" }, 500] } is true when amount is missing. Test presence explicitly: { "and": [{ "var": "args.amount" }, { "<": [{ "var": "args.amount" }, 500] }] }. See Missing keys and comparisons.
Step 6 — Attach the guardrail to the tool
A guardrail governs nothing until attached. At the tool scope the tool carries its gate wherever it is bound. guardrail_ids is a list; several guardrails compose on one tool.
- CLI
- SDK
- curl
soat update-tool --tool-id "$BUDGET_TOOL_ID" \
--guardrail-ids "$GUARDRAIL_ID" | jq '{id, name, guardrail_ids}'
await adminSoat.tools.updateTool({
path: { tool_id: BUDGET_TOOL_ID },
body: { guardrail_ids: [GUARDRAIL_ID] },
});
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/tools/$BUDGET_TOOL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"guardrail_ids\":[\"$GUARDRAIL_ID\"]}" | jq '{id, name, guardrail_ids}'
Adding an id needs only tools:UpdateTool (it can only tighten); removing one additionally requires guardrails:DetachGuardrail (Step 13).
Step 7 — Drive the tool from an orchestration
An orchestration tool node is gated at dispatch like an agent tool call; with no agent in scope it composes the project + tool scopes only. The apply node feeds the run's amount in as args.amount. The unlabeled edge is the success path; blocked / tripwire edges catch a refusal, which is a routable outcome, not a run failure.
- CLI
- SDK
- curl
ORCH_NODES='[
{"id":"apply","type":"tool","tool_id":"'"$BUDGET_TOOL_ID"'","operation_id":"get-project","input_mapping":{"amount":{"var":"input.amount"}}},
{"id":"done","type":"transform","expression":"Budget updated.","state_mapping":{"state.outcome":{"var":"output.result"}}},
{"id":"halted","type":"transform","expression":"Stopped by a guardrail.","state_mapping":{"state.outcome":{"var":"output.result"}}}
]'
ORCH_EDGES='[
{"from":"apply","to":"done"},
{"from":"apply","to":"halted","condition":"blocked"},
{"from":"apply","to":"halted","condition":"tripwire"}
]'
ORCHESTRATION_ID=$(soat create-orchestration \
--project-id "$PROJECT_ID" \
--name "Budget Update Pipeline" \
--description "Applies a budget change through the gated tool" \
--nodes "$ORCH_NODES" \
--edges "$ORCH_EDGES" | jq -r '.id')
echo "ORCHESTRATION_ID: $ORCHESTRATION_ID"
const { data: orchestration } =
await adminSoat.orchestrations.createOrchestration({
body: {
project_id: PROJECT_ID,
name: 'Budget Update Pipeline',
description: 'Applies a budget change through the gated tool',
nodes: [
{
id: 'apply',
type: 'tool',
tool_id: BUDGET_TOOL_ID,
operation_id: 'get-project',
input_mapping: { amount: { var: 'input.amount' } },
},
{
id: 'done',
type: 'transform',
expression: 'Budget updated.',
state_mapping: { 'state.outcome': { var: 'output.result' } },
},
{
id: 'halted',
type: 'transform',
expression: 'Stopped by a guardrail.',
state_mapping: { 'state.outcome': { var: 'output.result' } },
},
],
edges: [
{ from: 'apply', to: 'done' },
{ from: 'apply', to: 'halted', condition: 'blocked' },
{ from: 'apply', to: 'halted', condition: 'tripwire' },
],
},
});
const ORCHESTRATION_ID = orchestration.id;
ORCHESTRATION_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestrations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Budget Update Pipeline\",\"description\":\"Applies a budget change through the gated tool\",\"nodes\":[{\"id\":\"apply\",\"type\":\"tool\",\"tool_id\":\"$BUDGET_TOOL_ID\",\"operation_id\":\"get-project\",\"input_mapping\":{\"amount\":{\"var\":\"input.amount\"}}},{\"id\":\"done\",\"type\":\"transform\",\"expression\":\"Budget updated.\",\"state_mapping\":{\"state.outcome\":{\"var\":\"output.result\"}}},{\"id\":\"halted\",\"type\":\"transform\",\"expression\":\"Stopped by a guardrail.\",\"state_mapping\":{\"state.outcome\":{\"var\":\"output.result\"}}}],\"edges\":[{\"from\":\"apply\",\"to\":\"done\"},{\"from\":\"apply\",\"to\":\"halted\",\"condition\":\"blocked\"},{\"from\":\"apply\",\"to\":\"halted\",\"condition\":\"tripwire\"}]}" \
| jq -r '.id')
echo "ORCHESTRATION_ID: $ORCHESTRATION_ID"
Step 8 — Class B with a passing guard: the call just runs
A run with amount: 150 classifies B, passes the guard, and dispatches the tool.
- CLI
- SDK
- curl
RUN1=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":150}' --wait true)
RUN1_ID=$(printf '%s\n' "$RUN1" | jq -r '.id')
printf '%s\n' "$RUN1" | jq '{status, required_action}'
soat get-orchestration-run --orchestration-run-id "$RUN1_ID" | jq '{outcome: .state.outcome}'
Expected output:
{ "status": "succeeded", "required_action": null }
{ "outcome": "Budget updated." }
const { data: run1 } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: { amount: 150 },
wait: true,
},
});
console.log(run1.status); // succeeded
console.log(run1.state.outcome); // Budget updated.
RUN1=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":150},\"wait\":true}")
RUN1_ID=$(printf '%s\n' "$RUN1" | jq -r '.id')
printf '%s\n' "$RUN1" | jq '{status, required_action}'
Step 9 — Class C: the run parks for sign-off
amount: 900 classifies C: the tool is not dispatched, the run parks as awaiting_input, and an approval item carries the frozen arguments.
- CLI
- SDK
- curl
RUN2=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":900}' --wait true)
RUN2_ID=$(printf '%s\n' "$RUN2" | jq -r '.id')
APPROVAL_ID=$(printf '%s\n' "$RUN2" | jq -r '.required_action.approval_id')
printf '%s\n' "$RUN2" | jq '{status, required_action}'
Expected output:
{
"status": "awaiting_input",
"required_action": {
"type": "approval",
"node_id": "apply",
"prompt": "Approval required for tool call.",
"context": { "amount": 900 },
"approval_id": "apr_...",
"expires_at": "..."
}
}
The item records which guardrail and which version sent the call:
soat get-approval --approval-id "$APPROVAL_ID" \
| jq '{status, origin, orchestration_run_id, node_id, proposed_action, policy_version}'
{
"status": "pending",
"origin": "node",
"orchestration_run_id": "orch_run_...",
"node_id": "apply",
"proposed_action": { "tool_id": "tool_...", "arguments": { "amount": 900 } },
"policy_version": "guard_...@1"
}
Approve, and the node re-dispatches the tool with the frozen arguments:
soat approve-approval --approval-id "$APPROVAL_ID" | jq '{status, resolved_by}'
soat get-orchestration-run --orchestration-run-id "$RUN2_ID" | jq '{status, outcome: .state.outcome}'
{ "status": "succeeded", "outcome": "Budget updated." }
const { data: run2 } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: { amount: 900 },
wait: true,
},
});
console.log(run2.status); // awaiting_input
const APPROVAL_ID = run2.required_action.approval_id;
const { data: item } = await adminSoat.approvals.getApproval({
path: { approval_id: APPROVAL_ID },
});
console.log(item.proposed_action, item.policy_version);
await adminSoat.approvals.approveApproval({
path: { approval_id: APPROVAL_ID },
body: {},
});
const { data: resumed } = await adminSoat.orchestrations.getOrchestrationRun({
path: { orchestration_run_id: run2.id },
});
console.log(resumed.status, resumed.state.outcome); // succeeded Budget updated.
RUN2=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":900},\"wait\":true}")
RUN2_ID=$(printf '%s\n' "$RUN2" | jq -r '.id')
APPROVAL_ID=$(printf '%s\n' "$RUN2" | jq -r '.required_action.approval_id')
curl -s "$SOAT_BASE_URL/api/v1/approvals/$APPROVAL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{proposed_action, policy_version}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" -d '{}' | jq '{status}'
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$RUN2_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{status, outcome: .state.outcome}'
On approval the tool is re-dispatched with the frozen (or edited) arguments; the guardrail is not re-evaluated. On rejection or expiry the tool never runs and only a matching rejected / expired edge follows.
Step 10 — A failing guard: the tripwire
amount: 450 classifies B but fails the < 200 guard. By default a failing class-B guard is a tripwire: the action aborts.
- CLI
- SDK
- curl
RUN3=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":450}' --wait true)
RUN3_ID=$(printf '%s\n' "$RUN3" | jq -r '.id')
soat get-orchestration-run --orchestration-run-id "$RUN3_ID" \
| jq '{status, outcome: .state.outcome, refusal: .artifacts.apply}'
Expected output (the run succeeds down the tripwire edge):
{
"status": "succeeded",
"outcome": "Stopped by a guardrail.",
"refusal": {
"status": "tripwire",
"reason": "A guardrail tripwire fired: a class-B guard failed and the action was aborted."
}
}
A tripwire also files an exception:
soat list-exceptions --project-id "$PROJECT_ID" --kind guardrail_tripwire \
| jq '.data[0] | {kind, severity, status, title, occurrence_count}'
{
"kind": "guardrail_tripwire",
"severity": "warning",
"status": "open",
"title": "Guardrail tripwire aborted update-budget",
"occurrence_count": 1
}
const { data: run3 } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: { amount: 450 },
wait: true,
},
});
const { data: settled } = await adminSoat.orchestrations.getOrchestrationRun({
path: { orchestration_run_id: run3.id },
});
console.log(settled.state.outcome); // Stopped by a guardrail.
console.log(settled.artifacts.apply); // { status: 'tripwire', reason: '...' }
const { data: exceptions } = await adminSoat.exceptions.listExceptions({
params: { query: { project_id: PROJECT_ID, kind: 'guardrail_tripwire' } },
});
console.log(exceptions.data[0].title);
RUN3=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":450},\"wait\":true}")
RUN3_ID=$(printf '%s\n' "$RUN3" | jq -r '.id')
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$RUN3_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '{status, outcome: .state.outcome, refusal: .artifacts.apply}'
curl -s "$SOAT_BASE_URL/api/v1/exceptions?project_id=$PROJECT_ID&kind=guardrail_tripwire" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.data[0] | {kind, severity, title}'
"escalate": true in the document routes a failing guard to the approvals queue instead of aborting. It is per-guardrail; a tripwire from another applying guardrail still wins.
Step 11 — Read the governance trail
Every evaluation writes a guardrail_evaluation record. Those that changed the outcome (route_to_approval, blocked, tripwire, not plain execute) are also mirrored into the audit log as platform-originated entries.
- CLI
- SDK
- curl
soat list-audit-entries --project-id "$PROJECT_ID" --action "guardrails:Evaluate" \
| jq '[.data[] | {resource_srn, class: .detail.class, decision: .detail.decision, approval_id: .detail.approval_id}]'
Expected output (the class-C route and the tripwire; the class-B execute from Step 8 stays only in the guardrail's own evaluation records):
[
{
"resource_srn": "srn:proj_...:guardrail:guard_...",
"class": "B",
"decision": "tripwire",
"approval_id": null
},
{
"resource_srn": "srn:proj_...:guardrail:guard_...",
"class": "C",
"decision": "route_to_approval",
"approval_id": "apr_..."
}
]
Each entry's detail carries context_snapshot: only the vars the evaluation referenced, frozen at evaluation-time values.
const { data: audit } = await adminSoat.auditLog.listAuditEntries({
params: { query: { project_id: PROJECT_ID, action: 'guardrails:Evaluate' } },
});
for (const entry of audit.data) {
console.log(entry.detail.class, entry.detail.decision, entry.detail.context_snapshot);
}
curl -s "$SOAT_BASE_URL/api/v1/audit-log?project_id=$PROJECT_ID&action=guardrails:Evaluate" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {class: .detail.class, decision: .detail.decision}]'
Step 12 — Raise the floor for the whole project
There is no override resource; attach a tighter guardrail at the project scope. An always-C document forces sign-off on every tool call in the project. Composition is stricter-wins, so the amount: 150 call from Step 8 now parks.
- CLI
- SDK
- curl
BASELINE_ID=$(soat create-guardrail \
--project-id "$PROJECT_ID" \
--name "Sign-off Baseline" \
--document '{"class": "C"}' | jq -r '.id')
soat update-project --project-id "$PROJECT_ID" \
--guardrail-ids "$BASELINE_ID" | jq '{guardrail_ids}'
RUN4=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":150}' --wait true)
printf '%s\n' "$RUN4" | jq '{status, node_id: .required_action.node_id}'
soat get-approval --approval-id "$(printf '%s\n' "$RUN4" | jq -r '.required_action.approval_id')" \
| jq '{policy_version}'
Expected output (policy_version names the baseline as the governing guardrail):
{ "status": "awaiting_input", "node_id": "apply" }
{ "policy_version": "guard_...@1" }
const { data: baseline } = await adminSoat.guardrails.createGuardrail({
body: {
project_id: PROJECT_ID,
name: 'Sign-off Baseline',
document: { class: 'C' },
},
});
await adminSoat.projects.updateProject({
path: { project_id: PROJECT_ID },
body: { guardrail_ids: [baseline.id] },
});
const { data: run4 } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: { amount: 150 },
wait: true,
},
});
console.log(run4.status); // awaiting_input — the tool-scoped guardrail said execute
BASELINE_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/guardrails" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Sign-off Baseline\",\"document\":{\"class\":\"C\"}}" \
| jq -r '.id')
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"guardrail_ids\":[\"$BASELINE_ID\"]}" | jq '{guardrail_ids}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":150},\"wait\":true}" \
| jq '{status}'
Other projects are untouched. A tenant can raise the floor, never lower it.
Step 13 — Edits are versioned, detach is gated
Every write that changes document increments version and archives it, so an approval item's policy_version resolves to the exact governing text. A version's config holds { document }. Metadata-only edits and unchanged documents archive nothing. See Versioning.
- CLI
- SDK
- curl
soat update-guardrail --guardrail-id "$GUARDRAIL_ID" --document '{
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 500] }, "B", "C"] },
"guard": { "<": [{ "var": "args.amount" }, 100] }
}' | jq '{version}'
soat get-guardrail-version --guardrail-id "$GUARDRAIL_ID" --version 1 \
| jq '{version, guard: .config.document.guard}'
Expected output (version: 2 live; version 1's < 200 guard still retrievable):
{ "version": 2 }
{ "version": 1, "guard": { "<": [{ "var": "args.amount" }, 200] } }
Attachments reference the guardrail's id, not a version, so the edit applies immediately everywhere; dry-run edits first when attached at scale.
While the guardrail is attached, delete-guardrail returns 409 listing every reference:
# → expect-fail
soat delete-guardrail --guardrail-id "$GUARDRAIL_ID"
{
"status": 409,
"error": {
"code": "GUARDRAIL_HAS_REFERENCES",
"message": "Guardrail 'guard_...' is still attached and cannot be deleted. Detach it from every tool, agent, and project first.",
"meta": { "references": { "tools": ["tool_..."], "agents": [], "projects": [] } }
}
}
Detach first (guardrails:DetachGuardrail on top of tools:UpdateTool), then delete:
soat update-tool --tool-id "$BUDGET_TOOL_ID" --guardrail-ids '[]' | jq '{guardrail_ids}'
soat delete-guardrail --guardrail-id "$GUARDRAIL_ID"
const { data: updated } = await adminSoat.guardrails.updateGuardrail({
path: { guardrail_id: GUARDRAIL_ID },
body: {
document: {
default_class: 'C',
class: { if: [{ '<': [{ var: 'args.amount' }, 500] }, 'B', 'C'] },
guard: { '<': [{ var: 'args.amount' }, 100] },
},
},
});
console.log(updated.version); // 2
const { data: v1 } = await adminSoat.guardrails.getGuardrailVersion({
path: { guardrail_id: GUARDRAIL_ID, version: 1 },
});
console.log(v1.config.document.guard); // { '<': [{ var: 'args.amount' }, 200] }
// Deleting while attached fails with 409 GUARDRAIL_HAS_REFERENCES.
await adminSoat.tools.updateTool({
path: { tool_id: BUDGET_TOOL_ID },
body: { guardrail_ids: [] },
});
await adminSoat.guardrails.deleteGuardrail({
path: { guardrail_id: GUARDRAIL_ID },
});
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"document":{"default_class":"C","class":{"if":[{"<":[{"var":"args.amount"},500]},"B","C"]},"guard":{"<":[{"var":"args.amount"},100]}}}' \
| jq '{version}'
curl -s "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID/versions/1" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{version, guard: .config.document.guard}'
# 409 while still attached
curl -s -X DELETE "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.error.code'
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/tools/$BUDGET_TOOL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" -d '{"guardrail_ids":[]}' | jq '{guardrail_ids}'
curl -s -X DELETE "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" -o /dev/null -w '%{http_code}\n'
Next Steps
- Per-run spend ceilings —
guardrail_context,context_tool_id,runtime.orchestrations.tokens.total. approvalnode — an explicit human decision point in the graph.- Cap Spend Per End User — aggregate spend caps.
- Exceptions — triage what a tripwire files.