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_fraction or max_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"
}
FieldTypeRangeEffect
desk_idstring1–64 chars (alphanum + dot/dash/underscore/space)Free-text label, surfaced in audit + binding_constraint reason
max_grossnumber0 < x ≤ 1Caps exposure_policy.max_size_fraction
max_per_assetobject{ BTC?: 0..1, ETH?: 0..1 }Asset-specific caps applied on top of max_gross for that asset
leverage_capnumber1 ≤ x ≤ 10Caps exposure_policy.max_leverage. If < 1.01, leverage actions move from allowed to blocked.
blocked_protocolsstring[]subset of ["spark", "aave"]If the request protocol matches, returns 403 before any compute
concentration_limitnumber0 < x ≤ 1Stored only — enforcement deferred to portfolio API B1 (post-pilot)
notesstringup to 1024 charsFree-text, surfaced in audit
updated_at, updated_by, versionServer-managed (do not send)

Endpoints

MethodPathAuthBehaviour
GET/v2/keys/policyany authenticatedReturns the caller's own profile
GET/v2/keys/policy?key_prefix=...owner onlyReturns 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 onlyClears 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 outputConfig appliedResult
max_size_fraction = 0.65max_gross = 0.400.40 (config bound)
max_size_fraction = 0.30max_gross = 0.400.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=BTCmax_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 fieldSource
policy_config_appliedThe full { changes, binding, profile_snapshot } block as it appeared in the response
policy_config_snapshotA 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

FieldConstraintError code
desk_idstring, 1–64 chars, alphanumeric + _, -, ., spacedesk_id length must be 1..64 / desk_id allows alphanumerics, dot, dash, underscore, space
max_grossnumber, (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_capnumber, [1, 10]leverage_cap must be a number in [1, 10]
blocked_protocolsarray of known protocol namesblocked_protocols: unknown protocol "..."
concentration_limitnumber, (0, 1]concentration_limit must be a number in (0, 1]
notesstring, ≤ 1024 charsnotes 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_by and updated_at are 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_limit is stored only. Single-asset /v1/risk-state doesn't have portfolio context to enforce concentration limits — that lands with POST /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-state cache is keyed by (asset, protocol) only — config is layered per-call. Multiple customers share the cache; only the per-call delta is computed.