Audit Log

Every billed decision returned by POST /v1/risk-state is persisted to an append-only audit log. Read it via GET /v2/audit/decisions. This is the system of record for compliance, post-trade review, distribution analysis and procurement (SIG Lite) responses.

Append-only, not WORM. The MVP relies on Netlify Blobs storage integrity. There is no per-record hash chain or signed daily bundle in beta. Both ship as production-pilot options (Hardening Modes).

Status: v1, shipped 2026-04-28. Sync write inline with 5-second timeout. Beta default audit_mode: fail_open; production-pilot opt-in audit_mode: fail_closed. 365-day retention. Owner sees all; external API keys see only their own decisions.

Contents

How writes work

When a request reaches POST /v1/risk-state and is authorised, the engine computes a response (or serves the 60-second cache) and writes an audit record before the response is returned to the caller.

PropertyValue
PersistenceSync await on the audit write before responding
Inner timeout5 seconds (record + daily index update)
Failure modeFail-OPEN — if the audit write fails, the API still responds; the failure is logged
CoverageBoth fresh-compute and cache-hit paths
StorageNetlify Blobs store audit-decisions
Production-pilot optionaudit_mode: fail_closed returns 503 audit_unavailable if the write fails. Beta is fail_open.

Cache-hit decisions are recorded with cache_hit: true so distribution analysis can separate served-from-cache from fresh-compute.

Endpoints

List decisions

GET /v2/audit/decisions

Query parameters

ParamTypeDefaultNotes
fromYYYY-MM-DD30 days agoInclusive lower bound
toYYYY-MM-DDtodayInclusive upper bound
cursorstringOpaque cursor returned by the previous page
limitint1000Max per page (hard cap 5000)
key_prefixstringOwner-only: narrow to one customer's decisions

Response

{
  "decisions": [
    {
      "id": "dec_1777367330304_362m5s",
      "ts_epoch": 1777367330,
      "key_prefix": "owner",
      "key_type": "owner",
      "asset": "BTC",
      "policy_level": 3,
      "direction_bias": "NEUTRAL",
      "market_regime": "RANGE",
      "cache_hit": false,
      "latency_ms": 10901,
      "policy_hash": "e2acbda9b1d2183b43dec76b21c9a7c0c7ace8032c24005b540c6226591e01d6"
    }
  ],
  "next_cursor": null,
  "count": 1,
  "range": { "from": "2026-04-28", "to": "2026-04-28" },
  "key_type": "owner",
  "retention_days": 365
}

The list response carries summaries only. To pull the full record (including data_sources, binding_constraint, request, etc.), call the single-record endpoint with the decision ID.

Get a single decision

GET /v2/audit/decisions/{id}

For owner keys, you must supply ?key_prefix=... because records are partitioned by key prefix on disk. For external keys, the prefix is implied (you can only fetch your own records).

Auth header

All audit endpoints use the same Bearer token as /v1/risk-state:

Authorization: Bearer <api_key>

Record schema

A full record (~1–2 KB):

{
  "id": "dec_1777367330304_362m5s",
  "ts": "2026-04-28T09:08:50.304Z",
  "ts_epoch": 1777367330,
  "key_prefix": "rs_live_a3f2",
  "key_type": "external",
  "api_version": "1.4.0",
  "scoring_version": "score_v3",
  "policy_hash": "e2acbda9b1d2...",
  "request": {
    "asset": "BTC",
    "market_type": "spot",
    "has_wallet": false,
    "include_details": true,
    "reference_time": null,
    "allow_degraded": false,
    "protocol": "spark"
  },
  "response_summary": {
    "policy_level": 3,
    "max_size_fraction": 0.4192,
    "max_leverage": "1x",
    "leverage_allowed": false,
    "direction_bias": "NEUTRAL",
    "direction_layer": "tactical",
    "reduce_recommended": false,
    "allowed_actions": ["DCA", "WAIT", "LIGHT_ACCUMULATION", "RR_GT_2"],
    "blocked_actions": ["LEVERAGE", "AGGRESSIVE_LONG", "ALL_IN"],
    "structural_blockers": [],
    "context_risks": ["ETF_OUTFLOW"],
    "binding_constraint": {
      "source": "CYCLE",
      "reason": "POST-PEAK",
      "reason_codes": ["CYCLE_POST-PEAK"],
      "cap_value": 0.5
    },
    "confidence_score": 0.48,
    "data_quality_score": 100,
    "regime": {
      "tactical_state": "NEUTRAL",
      "structural_state": "POST-PEAK",
      "macro_state": "NEUTRAL",
      "market_regime": "RANGE",
      "volatility_regime": "LOW"
    },
    "stale_fields": []
  },
  "data_sources": {
    "prices": "LIVE",
    "rsi": "CC_FALLBACK",
    "funding": "OKX",
    "mvrv": "ESTIMATED",
    "...": "..."
  },
  "cache_hit": false,
  "latency_ms": 10901
}

What is deliberately NOT recorded:

  • Full response body — verifiable from policy_hash + scoring_version + ts against a re-run of the engine on the same inputs. Saving the body would inflate storage 5–10× without adding integrity beyond what the hash already provides.
  • Wallet addresses — only the boolean has_wallet is recorded (privacy by data-minimisation).
  • Raw upstream API responses — only the data_sources provider tags identifying which provider supplied each field. Pilots requiring full input replay can opt into data_mode: normalized_snapshot (Hardening Modes) which records the engine's normalized input vector at decision time.
  • API key — only the first 16 chars (key_prefix) for partitioning.

policy_hash claim, sharpened. The hash is integrity-anchored, not "re-derivable from nothing". With identical inputs and reference_time, two engine runs produce the same hash — that proves the response is consistent with a known engine state. Full reconstruction without the original inputs requires re-fetching from the same providers at the same timestamp; some upstream APIs do not honour past timestamps. Pilots that need pure-from-record reconstruction should request data_mode: normalized_snapshot.

Retention

SettingValueWhere
Retention365 daysnetlify/lib/audit.js (AUDIT_RETENTION_DAYS)
Cleanup cronDaily 03:30 UTCnetlify/functions/audit-cleanup.js (schedule: "30 3 * * *")
BehaviourWalks idx/{day}.json keys, deletes records + index entries with day older than the cutoff

365 days satisfies typical SaaS procurement and SOC 2 audit cycles. Longer retention (e.g. 7 years for regulated broker-dealers) is a per-customer setting that ships with the institutional pricing tier post-freeze.

Authentication and scoping

CallerVisibility
Owner key (RISKSTATE_API_KEY)All decisions, all customers
Owner with ?key_prefix=...Single customer view
External key (rs_live_*)Only own decisions; key_prefix filter ignored

Cross-customer reads are not possible from any external key, regardless of query parameters. The single-record endpoint additionally enforces this with a 403 if the record's key_prefix doesn't match the caller.

Use cases

Compliance and post-trade review. Every decision your trading system acted on (or chose to override) is integrity-anchored: policy_hash + scoring_version + ts lets a counterparty verify that the response is consistent with a known engine state. The data_sources snapshot tells you which providers contributed at decision time, including fallbacks and degraded modes.

Distribution analysis. Pull a date range, group by policy_level × market_regime, compare blocking frequencies. Useful inputs:

  • latency_ms distribution per asset
  • cache_hit ratio
  • binding_constraint.source distribution (which cap binds most often)
  • direction_layer mix (tactical vs structural vetoes)

Vendor auditability. A counterparty can verify any historical decision against its policy_hash. If our engine output disagrees with their reading later, the original record (with scoring_version pinned and the full response summary) is the tiebreaker.

Procurement / SIG Lite. See the mapping below.

SIG Lite mapping

Common SIG Lite questions and the audit record fields that answer them:

SIG Lite areaQuestionRecord field(s)
Logging"Are all access events logged?"Every authenticated /v1/risk-state call produces a record with key_prefix, key_type, ts.
Logging"What is the retention period?"retention_days in the list response (365).
Data integrity"How are decisions made reproducible?"policy_hash (SHA-256 over the input set) + scoring_version is integrity-anchored: identical inputs + reference_time produce the same hash. Full reconstruction beyond integrity verification requires the input snapshot — opt into data_mode: normalized_snapshot if needed.
Data integrity"How is data freshness audited?"data_sources block records the provider tag (LIVE, CC_FALLBACK, OKX, ESTIMATED, etc.) for every input field. stale_fields lists any core signal that was missing at decision time.
Privacy"Are user identifiers logged?"API key first 16 chars only (key_prefix). Wallet address NOT logged — only has_wallet boolean.
Privacy"Are full request bodies logged?"No. Only the request shape (asset, market_type, include_details, etc.) — re-derivable from policy_hash.
Operational"What is your incident detection latency?"Every record carries latency_ms; degraded performance surfaces immediately in the audit feed.

Limitations

  • Append-only, not WORM. Tamper detection relies on Netlify Blobs storage integrity. Records are not cross-linked into a Merkle / hash-chain structure in beta. A signed daily bundle (JSONL + SHA-256 manifest, optional S3 export) is the production-pilot option (retention_mode: 7y_export) — flag if procurement requires it.
  • No raw input snapshot in beta. Provider tags (data_sources) tell you which API supplied each field. They do not let you reconstruct the exact value if the upstream provider revises history. Pilots needing pure-from-record reconstruction should opt into data_mode: normalized_snapshot.
  • Fail-OPEN audit in beta. If the audit write fails, the API still responds. Pilots that require the property "no audit, no decision" should opt into audit_mode: fail_closed.
  • Daily index race. Two writes hitting the same calendar day within milliseconds can briefly compete on the read-modify-write of idx/{day}.json. The full record is still persisted at dec/{key_prefix}/{id}.json. Recovery via prefix scan is supported by the diagnostic endpoint. Sharded hourly indexes are planned for the institutional tier.
  • No live tail / streaming. The current endpoint is paginated polling. Webhook subscriptions for policy_state_change, blocker_activated, etc. are scoped for the post-freeze window.