The referee is Vector's bounded-execution gate (architecture §6.3): the single
path from a validated, signed Intent to the execution rail (boundary B2). It takes
a typed Intent — never a prompt — evaluates it against a fixed, ordered rule
set, and reduces it to one of four decisions, emitting one policy_event per
decision.
evaluate(intent, state, config) is a pure, deterministic function: the same
inputs always yield the same decision and the same policy_event. Scoring,
routing, and execution live elsewhere — the referee only judges.
Evaluation runs in two phases (order is the single source of truth, see
lib/referee/rules/index.ts). A terminal decision (HALT/REJECT) must always
dominate a soft CLIP — otherwise an over-sized trade could trip an early clip
and pre-empt a later REJECT/HALT, slipping an over-leveraged / over-budget /
drawdown-breached trade through. So:
Phase 1 — blocking rules (first one that fires decides outright):
| # | Rule (rule_fired) |
Applies to | Condition | Decision | Severity |
|---|---|---|---|---|---|
| 1 | kill_switch |
all | global kill switch active | HALT | halt |
| 1b | agent_halt |
all | agent halted by operator (agents.status='halted') |
HALT | halt |
| 2 | market_whitelist |
open, modify, close | market not in market_whitelist (exact match) |
REJECT | hard |
| 3 | fresh_wallet_transfer_block |
transfer | destination not on address whitelist (or missing) | REJECT | hard |
| 4 | drawdown_breaker |
all | drawdown >= dd_breaker |
HALT | halt |
| 5 | spend_cap |
open, modify | remaining_budget <= 0 |
REJECT | soft |
Phase 2 — clipping rules (run only if nothing blocked; they accumulate):
| # | Rule (rule_fired) |
Applies to | Condition | Clamp |
|---|---|---|---|---|
| 6 | size_cap |
open, modify | size > max_trade_size |
size → max_trade_size |
| 7 | spend_cap |
open, modify | size > remaining_budget |
size → remaining_budget |
| 8 | leverage_cap |
open, modify | leverage > max_leverage |
leverage → max_leverage |
Every breached clip is applied in one CLIP: a lone clip is reported with its
own rule_fired; when several fire, rule_fired joins them with + (e.g.
size_cap+leverage_cap) and detail.clips[] records each rule's rationale. The
result therefore satisfies all caps at once — size <= min(max_trade_size, remaining_budget) and leverage <= max_leverage.
| — | allow | all | no rule fired | ALLOW | none |
| — | pre_validation | all (in runReferee) | P0.3 structural re-validation failed | REJECT | none |
| — | internal_error | all (in runReferee) | unexpected error during evaluation | REJECT | hard |
- ALLOW — Intent passes unchanged (
severity = none). - CLIP — a parameter is reduced to a cap (
severity = soft). The clip invalidates the original signature, so the Intent is never re-signed: the rail executes the post-clip parameters (modified_intent), while the originalintent_hash/signature survive indetail_json/ theintentsrow for audit only. - REJECT — Intent dropped.
hardfor the whitelist/transfer rules (the reputation-collapsing violations),softfor the budget rule,nonefor a pre-validation failure. - HALT — agent (drawdown) or everything (kill switch) is frozen
(
severity = halt).
- Caps use strict
>(rules 4–6): the cap value itself is permitted; only a value strictly above it is clipped. - The drawdown breaker uses
>=(rule 4): a circuit breaker trips on reaching its limit —drawdown == dd_breakerhalts. This is the fail-safe choice for a risk control, and is deliberately asymmetric to the caps. - Blocking beats clipping: a HALT/REJECT in phase 1 short-circuits before any
clip runs, so a soft clip can never pre-empt a terminal decision. Within phase
2 the clips accumulate — clipping
sizedoes not stop theleverage/budget clamps — so the post-clip Intent always satisfies every cap simultaneously.
transfer is the only fund-moving action (§8.2; "withdraw" is a descriptive
synonym). The address whitelist (policy.fresh_wallet_criteria.whitelist) is
an explicit override: a whitelisted destination is allowed even if it looks
fresh. Any other destination — including a transfer with no
target_address — is treated as a drain and rejected hard.
Wallet freshness (age_seconds < max_age_seconds, or
require_zero_history && !has_history) is supplied as state
(RefereeState.destination) because age/history are off-chain facts the referee
cannot derive. Freshness is recorded in detail_json (and feeds drain_r in
P1.2 via rule_fired) but never softens the decision: a non-whitelisted
transfer is always REJECT + hard. When destination metadata is absent the
destination is treated as fresh (fail-closed). Address matching is
case-insensitive (EVM addresses are case-insensitive; mixed case is only an
EIP-55 checksum).
Critical invariant, covered by unit + fuzz + e2e tests:
no transfer to a non-whitelisted address is ever ALLOWed or CLIPped.
Every decision (including pre_validation) writes one row via the P0.2
repository (lib/db/repos/policy-events.ts):
| column | value |
|---|---|
intent_id |
FK to the persisted intents row |
agent_id |
FK to agents (uuid — distinct from the Intent's string agent id) |
round_id |
FK to rounds |
rule_fired |
the deciding rule id (table above) |
decision |
ALLOW / CLIP / REJECT / HALT |
severity |
none / soft / hard / halt |
detail_json |
structured rationale; the canonical intent_hash is folded in here for audit (the table keys on intent_id, not the hash) |
policy_events is an append-only audit log: re-running the referee on the
same Intent yields the same decision (evaluate is pure) and appends another
event recording that re-evaluation.
- P0.3 (
lib/intent/validate.ts) owns structural validation — schema, signature, nonce, ttl, numeric bounds, target-address shape.runRefereere-runs it as defense-in-depth before any policy rule; a failure is thepre_validationREJECT. - The referee (here) owns trading policy — whitelist, caps, fresh-wallet/drain block, budget, drawdown. No scoring, routing, or execution.
- P1.2 consumes
policy_events(rule_fired, severity) to compute scoring penalties anddrain_r.
- Market whitelist is disclosed in REJECT
detail_json(rule_fired: 'market_whitelist'). The full whitelist array is included for operator observability, which is acceptable for the demo (the whitelist is non-secret). For production deployments, consider omitting thewhitelistfield from the detail, or replacing it with a stable count/hash, to avoid exposing the full allow-list to unauthenticated callers ofGET /api/policy-events.
evaluate is a pure judge over an injected RefereeState snapshot: it does
no IO, reads nothing from the DB, and decrements nothing. Several money-safety
properties therefore depend on how the (not-yet-built) orchestrator snapshots
state and serializes submissions. The caller MUST honor these:
- Budget enforcement is advisory under concurrency. The spend cap is
evaluated against the injected
remaining_budget; the referee never reserves or decrements it. Two distinct Intents (different nonces) from the same agent evaluated against the same snapshot can each pass and together exceed the round allocation. The caller must make admission atomic — reserve/decrement budget in the same transaction as the decision (e.g.UPDATE … SET remaining = remaining - $size WHERE remaining >= $size) or hold a per-(agent,round) lock around snapshot→decide→commit.policy.spend_cap(config) is not an enforced absolute backstop today — onlyremaining_budgetgates spend. Thespend_capfield is logged inpolicy_events.detail_jsonfor audit context only; it does not gate any decision. - HALT freshness is the caller's job.
killSwitch/drawdownare read from the snapshot taken beforerunReferee's async re-validation. To avoid an in-flight Intent slipping past a kill switch flipped mid-evaluation, gate execution (boundary B2) on a fresh kill-switch read, not just on the snapshotted decision. - Kill-switch default must be explicit.
getKillSwitchreturnsnulluntil the singleton row is first written; the caller must mapnullto a deliberateactive:false(fail-open default), not an accidental?? false. - Replay defense is not in
evaluate. Anti-replay belongs to P0.3/P0.2 (nonce uniqueness).runRefereeonly re-reads via the optionalisNonceUsedand performs no atomic reserve, so the caller (or a durable unique constraint) must enforce single-use nonces.