Skip to main content

Overview

ISCL maintains an append-only audit trail that logs every fund-affecting operation performed by the system. Every transaction build, policy evaluation, approval decision, signature, and broadcast is recorded with a timestamp and correlated by intentId, making it possible to reconstruct the complete lifecycle of any transaction from initial request through final broadcast. The audit trail is backed by SQLite in WAL (Write-Ahead Logging) journal mode, providing durable writes with concurrent read access. The AuditTraceService (in @clavion/audit) is the single writer; all Domain B services log through it.

Append-only

The service exposes no UPDATE or DELETE operations.

Correlated

Every event carries an intentId that ties it to a specific TxIntent.

Structured

Event payloads are stored as JSON, queryable via SQLite JSON functions.

Low-latency

Prepared statements are compiled once at startup and reused for every write.

Architecture

Design decisions

  • SQLite WAL mode enables concurrent reads (API history queries) while the service is writing new audit events. Set via PRAGMA journal_mode = WAL at database open.
  • Two tables separate high-frequency rate-limit ticks from structured audit events, preventing rate-limit counting from scanning the full event table.
  • Prepared statements (db.prepare()) are compiled once in the constructor and bound per-call, avoiding repeated SQL parsing overhead.
  • Four indexes cover the primary query patterns: lookup by intent, lookup by event type, chronological ordering, and rate-limit sliding-window counts.

Database schema

audit_events table

rate_limit_events table

Indexes

All four indexes are created at startup via CREATE INDEX IF NOT EXISTS:

Event type catalog

The following audit events are emitted across the ISCL codebase. Each event is logged via auditTrace.log(eventName, { intentId, ...fields }).

Transaction pipeline events (tx.ts)

Approval events (approval-service.ts, tx.ts)

Signing events (wallet-service.ts)

Skill registry events (skills.ts)

Complete event flow example

A typical successful transaction produces this sequence of audit events:
1

policy_evaluated

Policy says “require_approval”
2

approve_request_created

Approval prompt generated
3

approval_granted

User confirms, token issued
4

tx_built

BuildPlan created
5

signature_created

Transaction signed
6

tx_broadcast

Sent to network
A denied transaction may stop at step 1 (policy deny) or step 3 (user rejection).

Querying the audit trail

Via API

GET /v1/approvals/history?limit=N returns the most recent audit events across all intents. The limit query parameter is optional (default: 20, maximum: 100).
Response:
Events are returned in reverse chronological order (most recent first).

Programmatic access

The AuditTraceService exposes two read methods:
Both methods return AuditEvent[]:
getTrail() returns events in ascending chronological order (oldest first) to match the natural transaction lifecycle, while getRecentEvents() returns events in descending order (newest first) for dashboard display.

Direct SQLite queries

For ad-hoc investigation, query the SQLite database directly. The database file location is set at startup (typically ./data/audit.db or as configured via environment variables). Find all events for a transaction:
Find all denied transactions in the last 24 hours:
Count transactions per wallet in the last hour:
Find all policy denials with reasons:
List all broadcast failures:

Incident investigation

When investigating a suspicious or failed transaction, follow these steps to reconstruct the full picture.
1

Identify the intentId

If you have a transaction hash, find the corresponding intentId:
If you have a wallet address, find recent intents for that wallet:
2

Pull the full trail

Or programmatically:
3

Check the policy decision

Look for policy_evaluated events. If the decision was "deny", the reasons array explains why:
Common deny reasons include:
  • value_exceeds_max — transfer value exceeds maxValueWei in policy config
  • approval_exceeds_max — ERC-20 approval amount exceeds maxApprovalAmount
  • chain_not_allowedchainId not in allowedChains
  • recipient_not_in_allowlist — destination not in recipientAllowlist
  • contract_not_in_allowlist — contract not in contractAllowlist
  • risk_score_too_high — preflight risk score exceeds maxRiskScore
  • rate_limit_exceeded — wallet exceeded maxTxPerHour
4

Check the approval flow

For transactions requiring approval, look at the approval events:
  1. approve_request_created — approval prompt was generated.
  2. approval_granted or approval_rejected — CLI/programmatic approval outcome.
  3. web_approval_decided — web UI approval outcome (includes requestId and approved boolean).
If there is an approve_request_created but no subsequent grant/reject, the approval request likely expired (TTL is 300 seconds by default).
5

Verify signing and broadcast

  • signature_created confirms the transaction was signed. Check signerAddress and txRequestHash.
  • signing_denied means the WalletService refused. The reason field indicates why: missing policy decision, invalid approval token, policy deny, or locked key.
  • tx_broadcast confirms the signed transaction was sent to the network.
  • broadcast_failed indicates an RPC-level failure. The error field contains the RPC error message.
6

Cross-reference rate-limit events

If rate limiting is suspected, check how many transactions the wallet has executed recently:
Compare the count against the maxTxPerHour setting in your Configuration.

Rate limiting internals

Rate limiting uses a dedicated rate_limit_events table separate from the main audit trail for performance. This table receives a write on every non-denied transaction (both “allow” and “require_approval” outcomes), so it has a high write frequency.

How it works

Recording ticks: When a transaction passes the policy check (not denied), auditTrace.recordRateLimitTick(walletAddress) inserts a row with the current timestamp.
Counting recent transactions: Before evaluating policy, the route handler queries the sliding window count:
This executes:
Policy enforcement: The recentTxCount is passed to evaluate(), which compares it against policyConfig.maxTxPerHour. If exceeded, the policy returns decision: "deny" with reason "rate_limit_exceeded".

Configuration

Rate limiting is configured via the maxTxPerHour field in PolicyConfig:
The default value is 10 transactions per hour per wallet address. The sliding window is always 3,600,000 ms (1 hour). See Configuration Reference for full policy configuration.
The composite index idx_rate_wallet_ts on (wallet_address, timestamp) makes the sliding-window COUNT(*) query efficient even with high row counts. Rows are never deleted by the application. For long-running deployments, consider periodic cleanup of old rate-limit rows.

Compliance and retention

Append-only guarantee

The AuditTraceService class provides no UPDATE or DELETE methods. All writes go through the log() method (for audit events) and recordRateLimitTick() (for rate-limit ticks). This design ensures that once an event is written, it cannot be modified or removed through the application layer. The only mutating SQL statements in the service are:

Durability

SQLite WAL mode ensures that committed transactions survive process crashes. The WAL file (audit.db-wal) and shared-memory file (audit.db-shm) are managed automatically by SQLite. No additional configuration is required for crash recovery.

Backup procedures

Copy the database file while the application is running. SQLite WAL mode ensures read consistency. Copy all three files:
  • audit.db
  • audit.db-wal
  • audit.db-shm

Export to JSONL

For external analysis or archival, export the audit trail as JSON lines:
Each line is a self-contained JSON object suitable for ingestion into log aggregation systems (Elasticsearch, Loki, Datadog, etc.).

Retention and cleanup

The application does not enforce automatic retention policies. For long-running deployments:
  • Audit events should be retained indefinitely or per your compliance requirements. These are low-volume (one batch per transaction).
  • Rate-limit events accumulate faster and can be pruned periodically. Events older than the sliding window (1 hour) are no longer needed for rate limiting, but may be retained for analysis:
Run cleanup queries during maintenance windows or via a scheduled job. The composite index ensures the DELETE is efficient.

Database sizing

Approximate storage per record:
  • audit_events: ~300-500 bytes per event (UUID + timestamp + JSON payload)
  • rate_limit_events: ~60 bytes per tick (address + timestamp)
A deployment processing 100 transactions per day will produce roughly:
  • ~600 audit events/day (~200 KB/day)
  • ~100 rate-limit ticks/day (~6 KB/day)
At this rate, a year of uncompacted data is approximately 75 MB, well within SQLite’s practical limits.

Next steps