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-inaudit_mode: fail_closed. 365-day retention. Owner sees all; external API keys see only their own decisions.
Contents
- How writes work
- Endpoints
- Record schema
- Retention
- Authentication and scoping
- Use cases
- SIG Lite mapping
- Limitations
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.
| Property | Value |
|---|---|
| Persistence | Sync await on the audit write before responding |
| Inner timeout | 5 seconds (record + daily index update) |
| Failure mode | Fail-OPEN — if the audit write fails, the API still responds; the failure is logged |
| Coverage | Both fresh-compute and cache-hit paths |
| Storage | Netlify Blobs store audit-decisions |
| Production-pilot option | audit_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
| Param | Type | Default | Notes |
|---|---|---|---|
from | YYYY-MM-DD | 30 days ago | Inclusive lower bound |
to | YYYY-MM-DD | today | Inclusive upper bound |
cursor | string | — | Opaque cursor returned by the previous page |
limit | int | 1000 | Max per page (hard cap 5000) |
key_prefix | string | — | Owner-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+tsagainst 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_walletis recorded (privacy by data-minimisation). - Raw upstream API responses — only the
data_sourcesprovider tags identifying which provider supplied each field. Pilots requiring full input replay can opt intodata_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_hashclaim, sharpened. The hash is integrity-anchored, not "re-derivable from nothing". With identical inputs andreference_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 requestdata_mode: normalized_snapshot.
Retention
| Setting | Value | Where |
|---|---|---|
| Retention | 365 days | netlify/lib/audit.js (AUDIT_RETENTION_DAYS) |
| Cleanup cron | Daily 03:30 UTC | netlify/functions/audit-cleanup.js (schedule: "30 3 * * *") |
| Behaviour | Walks 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
| Caller | Visibility |
|---|---|
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_msdistribution per assetcache_hitratiobinding_constraint.sourcedistribution (which cap binds most often)direction_layermix (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 area | Question | Record 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 intodata_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 atdec/{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.