Policy Config
Each API key can be attached to a policy_config profile that further tightens the engine's caps. The profile encodes a customer's mandate (max gross exposure, per-asset caps, leverage ceiling, blocked protocols, desk identifier) and is applied to every /v1/risk-state response from that key.
Tighten only — never loosens. The engine output is the upper bound. Config can lower
max_size_fractionormax_leverage, but never raise them. Asking for a higher cap than the engine permits is silently ignored (no error, no upgrade).
Status: v1, shipped 2026-04-28. Owner-only writes — pilot customers do not self-serve writes in v1; RiskState walks customers through their initial config to avoid accidental self-loosening errors. v2 self-service is post-pilot.
Contents
Schema
{
"desk_id": "fund-alpha-eq",
"max_gross": 0.40,
"max_per_asset": { "BTC": 0.30, "ETH": 0.25 },
"leverage_cap": 1,
"blocked_protocols": ["aave"],
"concentration_limit": 0.50,
"notes": "Q3 2026 mandate — defensive posture",
"updated_at": "2026-04-28T14:21:08.117Z",
"updated_by": "owner",
"version": "1"
}
| Field | Type | Range | Effect |
|---|---|---|---|
desk_id | string | 1–64 chars (alphanum + dot/dash/underscore/space) | Free-text label, surfaced in audit + binding_constraint reason |
max_gross | number | 0 < x ≤ 1 | Caps exposure_policy.max_size_fraction |
max_per_asset | object | { BTC?: 0..1, ETH?: 0..1 } | Asset-specific caps applied on top of max_gross for that asset |
leverage_cap | number | 1 ≤ x ≤ 10 | Caps exposure_policy.max_leverage. If < 1.01, leverage actions move from allowed to blocked. |
blocked_protocols | string[] | subset of ["spark", "aave"] | If the request protocol matches, returns 403 before any compute |
concentration_limit | number | 0 < x ≤ 1 | Stored only — enforcement deferred to portfolio API B1 (post-pilot) |
notes | string | up to 1024 chars | Free-text, surfaced in audit |
updated_at, updated_by, version | — | — | Server-managed (do not send) |
Endpoints
| Method | Path | Auth | Behaviour |
|---|---|---|---|
GET | /v2/keys/policy | any authenticated | Returns the caller's own profile |
GET | /v2/keys/policy?key_prefix=... | owner only | Returns any customer's profile |
PUT | /v2/keys/policy?key_prefix=... | owner only (v1) | Creates / updates the customer's profile |
DELETE | /v2/keys/policy?key_prefix=... | owner only | Clears the customer's profile |
All endpoints use the same Bearer token as /v1/risk-state:
Authorization: Bearer <api_key>
Set a profile (owner)
curl -X PUT "https://riskstate.netlify.app/v2/keys/policy?key_prefix=rs_live_a3f2..." \
-H "Authorization: Bearer $RISKSTATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"desk_id": "fund-alpha-eq",
"max_gross": 0.40,
"leverage_cap": 1,
"blocked_protocols": ["aave"],
"notes": "Q3 2026 mandate"
}'
Successful response (200):
{
"prefix": "rs_live_a3f2...",
"policy_config": {
"desk_id": "fund-alpha-eq",
"max_gross": 0.4,
"leverage_cap": 1,
"blocked_protocols": ["aave"],
"notes": "Q3 2026 mandate",
"version": "1",
"updated_at": "2026-04-28T14:21:08.117Z",
"updated_by": "owner"
},
"config_version": "1"
}
Read your own profile (external key)
curl -sL "https://riskstate.netlify.app/v2/keys/policy" \
-H "Authorization: Bearer $YOUR_API_KEY"
External keys can read their profile for transparency. They cannot modify it in v1 — PUT returns 403.
Enforcement
When /v1/risk-state is called with a key that has a policy_config, two enforcement steps run:
1. Pre-flight protocol block
If the request includes a protocol that appears in blocked_protocols, the API returns 403 before any compute or cache lookup:
{
"error": "Protocol \"aave\" blocked by policy_config (desk_id=fund-alpha-eq)",
"reason_codes": ["CONFIG_PROTOCOL_BLOCKED"]
}
2. Cap tightening
After the engine produces its policy, config tightening runs against the response:
| Engine output | Config applied | Result |
|---|---|---|
max_size_fraction = 0.65 | max_gross = 0.40 | 0.40 (config bound) |
max_size_fraction = 0.30 | max_gross = 0.40 | 0.30 (engine still tighter) |
max_leverage = "2x" | leverage_cap = 1 | "1x", leverage_allowed = false, leverage actions moved to blocked_actions |
max_size_fraction = 0.50, asset=BTC | max_per_asset = { BTC: 0.20 } | 0.20 (asset cap binds) |
When config bound the result, the response carries:
{
"exposure_policy": {
"max_size_fraction": 0.40,
"max_leverage": "1x",
"leverage_allowed": false,
"...": "..."
},
"policy_level": 3,
"binding_constraint": {
"source": "CONFIG",
"reason": "policy_config (desk_id=fund-alpha-eq)",
"reason_codes": ["CONFIG_MAX_GROSS", "CONFIG_LEVERAGE_CAP"],
"cap_value": 0.40
},
"policy_config_applied": {
"desk_id": "fund-alpha-eq",
"changes": {
"max_gross_engine": 0.65,
"max_gross_config": 0.40,
"leverage_engine": 2,
"leverage_config": 1
},
"binding": { "source": "CONFIG", "...": "..." },
"profile_snapshot": {
"desk_id": "fund-alpha-eq",
"max_gross": 0.40,
"leverage_cap": 1,
"_at": "2026-04-28T14:21:08.117Z",
"_by": "owner",
"_v": "1"
}
}
}
policy_level (1–5) is re-derived from the final max_size_fraction so the integer tier reflects the post-config view, not the pre-config one.
binding_constraint.source = "CONFIG" makes it explicit that the customer's mandate is what bound this decision, not the engine's caps.
Audit trail
Every /v1/risk-state decision is persisted to the audit log with two extra fields when a config was applied:
| Audit field | Source |
|---|---|
policy_config_applied | The full { changes, binding, profile_snapshot } block as it appeared in the response |
policy_config_snapshot | A separate top-level snapshot of the active config at decision time |
The double-storage is intentional: policy_config_applied shows what the API surfaced to the caller; policy_config_snapshot is the canonical record for replay. If the customer's profile is later updated, the audit record still carries the version that produced this specific decision.
Validation rules
| Field | Constraint | Error code |
|---|---|---|
desk_id | string, 1–64 chars, alphanumeric + _, -, ., space | desk_id length must be 1..64 / desk_id allows alphanumerics, dot, dash, underscore, space |
max_gross | number, (0, 1] | max_gross must be a number in (0, 1] |
max_per_asset[*] | number per known asset, (0, 1] | max_per_asset.<ASSET> must be in (0, 1] |
leverage_cap | number, [1, 10] | leverage_cap must be a number in [1, 10] |
blocked_protocols | array of known protocol names | blocked_protocols: unknown protocol "..." |
concentration_limit | number, (0, 1] | concentration_limit must be a number in (0, 1] |
notes | string, ≤ 1024 chars | notes max length is 1024 chars |
PUT with no recognised fields returns 400 Empty profile — provide at least one field. Validation aggregates all errors per request — you don't need to play whack-a-mole.
Examples
Conservative single-asset BTC long-only desk
{
"desk_id": "btc-only-conservative",
"max_gross": 0.30,
"max_per_asset": { "ETH": 0 },
"leverage_cap": 1,
"blocked_protocols": ["aave", "spark"],
"notes": "BTC-only, no leverage, no DeFi borrowing"
}
Note: "ETH": 0 would fail validation (must be > 0). Use blocked_protocols and asset filtering at the integration layer for hard exclusions in v1; per-asset block tokens are on the v2 list.
Sized-down clone of the engine
{
"desk_id": "small-mandate",
"max_gross": 0.20,
"leverage_cap": 1
}
Every decision is the engine's result, capped at 20% gross with no leverage. Useful for funds whose internal risk committee mandates a hard cap regardless of market state.
Spark-only DeFi-aware desk
{
"desk_id": "defi-spark",
"max_gross": 0.60,
"leverage_cap": 1.5,
"blocked_protocols": ["aave"],
"notes": "Spark only — Aave V3 exposure not approved by treasury"
}
Requests with ?protocol=aave are rejected with 403 Protocol "aave" blocked by policy_config.
Limitations
- No customer self-service writes in v1. Owner sets the profile during pilot setup; customer can read it for transparency.
- No separation-of-duties workflow in v1.
updated_byandupdated_atare recorded, but there is no dual-approval, change-diff, version history or rollback. A risk committee that requires "who proposed, who approved, what was the diff, who can revert" should treat this as a v2 deliverable. For pilot, RiskState manages config changes manually with email confirmation. concentration_limitis stored only. Single-asset/v1/risk-statedoesn't have portfolio context to enforce concentration limits — that lands withPOST /v2/portfolio-risk-state(post-freeze window).- No per-asset full-block in v1 (e.g. "this desk doesn't trade ETH"). Use the integration layer to filter; per-asset hard-block tokens are post-pilot.
- Per-customer cache. The
/v1/risk-statecache is keyed by(asset, protocol)only — config is layered per-call. Multiple customers share the cache; only the per-call delta is computed.