Layered Enforcement Model
Vesanor enforces tool-call safety through four layers. Each layer catches a different class of problem. Lower layers are automatic; higher layers require more specification from you. All four compose — a tool call must pass every applicable layer to proceed.
The four layers
┌─────────────────────────────────────────────────────────────────┐
│ Layer 4: Human Checkpoints │
│ "Is this what we actually want?" │
│ Catches: intent errors, hallucinated goals, poisoned prompts │
├─────────────────────────────────────────────────────────────────┤
│ Layer 3: Session-Level Constraints │
│ Aggregates, envelopes, labels │
│ Catches: salami attacks, value escalation, context misuse │
├─────────────────────────────────────────────────────────────────┤
│ Layer 2: Cross-Tool Consistency │
│ Session bindings (ref operator) │
│ Catches: value drift between approval and execution │
├─────────────────────────────────────────────────────────────────┤
│ Layer 1: Per-Tool Validation │
│ Argument invariants, schema-derived bounds, preconditions │
│ Catches: out-of-range values, wrong types, missing approvals │
└─────────────────────────────────────────────────────────────────┘
Layers 1 and 2 are evaluated first. A tool call that fails Layer 1 is blocked before Layers 2–4 are considered. Checkpoints (Layer 4) only trigger after all structural validation passes — they are the last gate, not a replacement for it.
Layer 1: Per-Tool Validation
Per-tool contracts validate each call in isolation — argument values, types, and ordering constraints.
What it catches: A single call with an out-of-range value, an invalid argument, or a call made before its prerequisite.
Example: boundary values
A trading agent calls submit_live_order(shares=999999999). The tool schema says maximum: 1000000. Without enforcement, this passes because the LLM produced valid JSON.
Schema-derived invariants extract bounds from your tool's JSON Schema automatically:
{
"name": "submit_live_order",
"parameters": {
"type": "object",
"properties": {
"shares": { "type": "integer", "minimum": 1, "maximum": 1000000 },
"side": { "type": "string", "enum": ["buy", "sell"] }
}
}
}
Vesanor synthesizes these invariants at compile time — no YAML needed:
argument_value_invariants (auto-derived):
$.shares → gte: 1, lte: 1000000
$.side → one_of: ["buy", "sell"]
If the LLM sends shares: 999999999, it's blocked with argument_value_mismatch [schema-derived].
To add manual invariants beyond what the schema provides, or to override a schema-derived bound:
# submit_live_order.yaml
tool: submit_live_order
side_effect: financial
argument_value_invariants:
- path: $.shares
lte: 50000 # tighter than schema's 1M — manual wins
- path: $.symbol
regex: "^[A-Z]{1,5}$"
Opt out of schema-derived invariants per-tool (schema_derived: false) or per-field (schema_derived_exclude: [$.shares]) if the schema bounds are wrong for your use case.
Layer 2: Cross-Tool Consistency
Session bindings assert that a value from one tool call equals a value in a later call. They catch value drift between steps that should agree.
What it catches: Approval says 50,000 shares, but the execution submits 5,000,000. Each call passes its own contract, but the pair is inconsistent.
Example: approve-then-execute drift
# approve_risk_check.yaml
tool: approve_risk_check
side_effect: financial
binds:
- name: approved_shares
path: $.shares
- name: approved_order_id
source: output
path: $.approval_id
# submit_live_order.yaml
tool: submit_live_order
side_effect: financial
argument_value_invariants:
- path: $.shares
ref: approved_shares # must equal the approved value
- path: $.approval_ref
ref: approved_order_id # must match the approval ID
If approve_risk_check(shares=50000) is followed by submit_live_order(shares=5000000), the ref check fails with ref_mismatch:
expected: 50000 (from approve_risk_check, step 2)
actual: 5000000
For fields that should be close but not exact (floating-point values), use tolerance:
- path: $.notional_value
ref: approved_notional
tolerance: 0.01 # 1% relative tolerance
Bindings persist across phases. A value bound in pre_trade is available in post_trade. The last write wins if the producing tool is called multiple times (handles retry flows). To enforce single-approval semantics, pair binds with max_calls_per_tool: 1 on the producing tool.
Layer 3: Session-Level Constraints
Three primitives operate at the session level — aggregates, envelopes, and labels. Each addresses a different class of attack that per-tool and cross-tool checks miss.
Session aggregates
Aggregates constrain computed totals across all calls of a type: sums, counts, maximums, distinct values.
What it catches: 100 orders of 50,000 shares each. Every order passes its per-tool limit. But the session total is 5,000,000 shares — a salami attack.
# session.yaml
aggregates:
- name: total_shares
metric: sum
tool: submit_live_order
path: $.shares
lte: 100000
reason: "Total shares across all orders must not exceed risk limit"
- name: order_count
metric: count
tool: submit_live_order
lte: 3
reason: "Maximum 3 order submissions per session"
- name: distinct_counterparties
metric: count_distinct
tool: submit_live_order
path: $.counterparty_id
lte: 5
reason: "Maximum 5 distinct counterparties per session"
Aggregates use speculative evaluation: the system checks what the total would be if the call goes through, and blocks before committing. Metrics: sum, count, max, min, count_distinct. Aggregates accumulate across phase transitions (by design — salami attacks can span phases).
Value envelopes
Envelopes constrain directional flow between tools: values must decrease, stay within a band, or converge monotonically.
What it catches: Approved 50,000 shares, submitted 60,000. The binding check passes if the contract uses ref (exact match). But if the workflow allows submitting up to the approved amount, you need a ceiling, not equality.
# session.yaml
envelopes:
- name: risk_envelope
description: "Shares must not increase from approval to execution"
stages:
- tool: approve_risk_check
path: $.shares
role: ceiling
- tool: submit_live_order
path: $.shares
role: constrained
constraint: lte_ceiling
reason: "Submitted shares must not exceed approved shares"
- name: price_band
description: "Execution price within 5% of quote"
stages:
- tool: get_market_quote
path: $.price
role: anchor
- tool: submit_live_order
path: $.limit_price
role: constrained
constraint: within_band
band: 0.05
reason: "Execution price must be within 5% of market quote"
Constraints: lte_ceiling, gte_floor, within_band, monotonic_decrease, monotonic_increase, bounded.
Bindings vs. envelopes: Use bindings when values must match exactly (order ID, account number). Use envelopes when values must flow in a constrained direction (shares, prices, balances).
Session labels
Labels gate tool availability based on application-supplied context. The platform enforces gates; the application classifies what's sensitive.
What it catches: The LLM has Material Non-Public Information (MNPI) in its context window and makes structurally valid trading calls. Every contract passes — the LLM is calling the right tools with valid arguments. But trading while holding MNPI is illegal.
const client = replay({
agent: "trading-agent",
contracts: "packs/trading",
labels: ["mnpi"], // application declares the context
});
# session.yaml
policy:
label_gates:
- when_label: mnpi
deny_tools: [submit_live_order, place_trade, modify_order]
reason: "Trading tools blocked when MNPI is in context"
- when_label: pii-loaded
deny_tools: [export_to_csv, send_email]
reason: "Export tools blocked when PII is in context"
Labels follow taint semantics — they can be added mid-session (client.addLabel("pii-loaded")) but never removed. To clear a label, start a new session. This prevents "un-seeing" sensitive context.
Label gates are evaluated at Stage 1 (Narrow). The tool is removed from the tool set before the LLM sees it — the model never knows the tool exists for that call.
Layer 4: Human Checkpoints
Checkpoints pause the session and wait for a human decision before allowing an irreversible operation.
What it catches: Everything else. A structurally valid, aggregate-compliant, envelope-respecting, label-gated tool call can still be wrong if the LLM is hallucinating a scenario, following a poisoned prompt, or optimizing for the wrong goal. Automated validation cannot verify intent — only a human can.
Example: high-value trade approval
# submit_live_order.yaml
tool: submit_live_order
side_effect: financial
checkpoint:
when:
- path: $.notional_value
gte: 1000000
timeout_seconds: 300
on_timeout: deny
context:
- path: $.shares
label: "Shares"
- path: $.notional_value
label: "Notional Value"
- path: $.symbol
label: "Symbol"
When the condition triggers, the SDK calls your onCheckpoint handler:
const client = replay({
contracts: "packs/trading",
onCheckpoint: async (request) => {
// Route to Slack, email, dashboard, or any approval system
return await myApprovalSystem.requestApproval(request);
},
});
The session pauses. The LLM response is buffered. On approve, the call proceeds. On deny or timeout, the call is blocked.
Session-level checkpoints in session.yaml can trigger on side effects or apply to all calls of a type:
# session.yaml
checkpoints:
- name: any_destructive_op
tool: "*"
when_side_effect: destructive
timeout_seconds: 600
on_timeout: deny
- name: always_approve_deploy
tool: deploy_to_production
when: always
timeout_seconds: 900
on_timeout: deny
Checkpoints only trigger after all other validation passes. A call that fails an invariant or aggregate is blocked before the checkpoint is considered. In govern mode, checkpoints are enforced server-side to prevent a compromised client from auto-approving.
Which primitive do I need?
Start with the constraint you're trying to express, then pick the smallest primitive that handles it.
| Constraint | Primitive | Where to write it |
|---|---|---|
| Values must match across tools | Session bindings (ref) | binds on producer contract, ref on consumer contract |
| Values must not grow through a chain | Value envelopes (lte_ceiling) | envelopes in session.yaml |
| Total across all calls must be bounded | Session aggregates (sum, count) | aggregates in session.yaml |
| Context should restrict tool access | Session labels | labels in SDK init, label_gates in session.yaml policy |
| Tool schema already has bounds | Schema-derived (automatic) | Nothing — enabled by default |
| Irreversible operation needs human approval | Checkpoints | checkpoint on tool contract or checkpoints in session.yaml |
| Want to know what shadow mode hasn't tested | Shadow coverage | Nothing — check vesanor doctor or the Shadow dashboard |
Decision tree
-
Is the constraint about a single tool call's argument values?
- Yes, and the tool schema already declares bounds → schema-derived handles it (no action needed).
- Yes, and you need bounds beyond the schema → add
argument_value_invariantsto the tool contract.
-
Is the constraint about consistency between two specific tools?
- Values must be equal → session bindings (
ref). - Values must flow directionally (one must not exceed the other, within a band) → value envelopes.
- Values must be equal → session bindings (
-
Is the constraint about totals across many calls?
- Sum, count, max, min, distinct count → session aggregates.
-
Is the constraint about what the LLM knows (context), not what it does?
- Session labels. Your application classifies the context; the platform gates the tools.
-
Is the operation irreversible and high-stakes?
- Human checkpoints. No amount of structural validation replaces human judgment for "should we actually do this?"
-
Do you want to know where shadow testing has gaps?
- Shadow coverage tracking. Check the coverage report for zero-observation tools and untested interaction pairs.
Known limitations
What the system does not catch
Multi-session attacks. All session-level primitives (aggregates, envelopes, bindings, labels) reset when a new session starts. An attacker that splits work across sessions bypasses session-scoped constraints. Tenant-level aggregates (cross-session) are a future extension requiring server-side enforcement in govern mode.
Intent verification without checkpoints. Layers 1–3 validate structure, consistency, and bounds. They do not verify that the LLM's goal is correct. If you need intent verification, you need checkpoints — there is no automated substitute.
Semantic correctness of specifications. The system enforces your contracts faithfully. If you write lte: 999999999 when you meant lte: 50000, the system enforces 999999999. Schema-derived invariants help by using the tool author's bounds, but manual contracts are your responsibility.
Label accuracy. Labels are assertions by the application ("this session has MNPI"). The platform enforces them; it does not verify them. If the application fails to label sensitive context, tools remain available. Detection is the application's job; gating is the platform's.
Checkpoint bypass in monitor/protect modes. In monitor and protect modes, checkpoints are evaluated client-side. A compromised application can implement onCheckpoint as async () => ({ decision: "approve" }). Use govern mode for server-side checkpoint enforcement when the client is not fully trusted.
Interaction between independently-valid constraints. Each primitive is evaluated independently. A tool call can pass bindings but fail an envelope, or pass an envelope but fail an aggregate. The system does not reason about whether the combination of constraints is achievable — it just evaluates each one. Contract graph analysis (at compile time) can detect some impossible configurations, but not all.