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.
Enforcement scope today. Cap tightening (
max_gross,max_per_asset,leverage_cap) and the pre-flightblocked_protocols403 are applied on/v1/risk-stateonly. The v2 alpha endpoints (/v2/portfolio-risk-state,/v2/stress-scenario) record the active profile in their audit snapshot but do not enforce any of its fields today — they apply the engine's built-in thresholds instead. See Enforcement scope by endpoint for the precise table. Customer-tightened enforcement on the v2 endpoints lands jointly with v2 stabilisation at the freeze lift (2026-10-22).
Contents
- Schema
- Endpoints
- Enforcement scope by endpoint
- Enforcement (on /v1/risk-state)
- Audit trail
- Validation rules
- Examples
- Limitations
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 + audited, NOT enforced. Persisted in the profile and surfaced in the audit policy_config_snapshot of every /v2/portfolio-risk-state call. The v2 portfolio endpoint applies the engine's built-in thresholds instead (75% WARNING / 90% DANGER for crypto, 50/80% for stablecoins). Customer-tightened enforcement is on the post-freeze track. |
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://api.riskstate.ai/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://api.riskstate.ai/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 scope by endpoint
What each audited endpoint actually does with the active policy_config:
| Endpoint | blocked_protocols (pre-flight 403) | max_gross / max_per_asset cap | leverage_cap | concentration_limit | Audit policy_config_snapshot | policy_config_applied in response |
|---|---|---|---|---|---|---|
/v1/risk-state | ✅ Enforced | ✅ Enforced | ✅ Enforced (incl. moves leverage actions to blocked_actions when ≤ 1.01) | 🟡 Stored + audited (not directly applicable to single-asset) | ✅ Yes | ✅ Yes |
/v2/portfolio-risk-state | ❌ Not enforced today | ❌ Not enforced today (engine's per-asset caps apply) | ❌ Not enforced today | ❌ Not enforced today (engine thresholds 75/90% crypto, 50/80% stables apply) | ✅ Yes ({ keys: [...] }) | ❌ No |
/v2/stress-scenario | ❌ Not loaded | ❌ Not loaded | ❌ Not loaded | ❌ Not loaded | ❌ No | ❌ No |
What this means in practice for a pilot:
- A customer profile with
blocked_protocols: ["aave"]will reject the v1 request with 403 but will not reject a/v2/portfolio-risk-statecall. If the v2 portfolio endpoint matters to your mandate, exclude Aave at the integration layer until v2 enforcement ships. - A customer profile with
max_gross: 0.40tightens the v1 response'smax_size_fraction. The v2 portfolio endpoint's per-position assessments still respect the engine's per-assetmax_size_fraction(often 0.42–0.48), which may be looser than your mandate. - The audit trail correctly records the active profile on
/v1/risk-stateand/v2/portfolio-risk-state(so attribution and procurement reviews can prove the config was active), but only/v1/risk-statedecisions are config-enforced today.
Customer-tightened v2 enforcement (port the v1 applyConfigToResponse and checkBlockedProtocol paths into the portfolio endpoint, plus a per-position concentration check) is in scope alongside v2 stabilisation. Score_v3 freeze does not block this; it's straight enforcement plumbing.
Enforcement (on /v1/risk-state)
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
Audit field shape varies by endpoint.
/v1/risk-state (config enforced) records both:
| 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 canonical 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.
/v2/portfolio-risk-state (config loaded but not enforced today) records only:
| Audit field | Source |
|---|---|
policy_config_snapshot | { keys: [<active field names>] } — confirms a profile was active at decision time and lists which fields were configured (full values redacted from the snapshot pending v2 enforcement). Useful for "was the right desk profile active when this portfolio ran?" attribution even before v2 enforces the cap fields themselves. |
No policy_config_applied is emitted (nothing was applied).
/v2/stress-scenario does not load the profile and does not record either field.
See audit log for the full record shape per endpoint.
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
- v2 endpoints do not enforce policy_config today.
/v2/portfolio-risk-stateand/v2/stress-scenarioshipped in May 2026 alpha. The portfolio endpoint records a snapshot of the active profile in its audit record (so attribution still works) but does not currently enforceblocked_protocols,max_gross,max_per_asset,leverage_caporconcentration_limit. Stress-scenario doesn't load the profile at all. Customers whose mandate depends on these caps must exclude protocols / cap exposure at their integration layer until v2 enforcement plumbing ships. See Enforcement scope by endpoint for the precise table. - 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 + audited but not enforced today./v1/risk-stateis single-asset and has no portfolio context./v2/portfolio-risk-stateships in alpha and has the right context, but its v2 alpha line does not yet apply customer-tightened concentration — it uses the engine's built-in 75/90% (crypto) and 50/80% (stables) thresholds. Customer-tightened enforcement on the v2 portfolio endpoint is on the post-freeze plumbing list (no scoring change required, freeze-allowed).- 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.