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 byintentId, 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 = WALat 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
| Column | Type | Description |
|---|---|---|
id | TEXT PK | UUID v4, generated per event via crypto.randomUUID() |
timestamp | INTEGER | Unix epoch in milliseconds (Date.now()) |
intent_id | TEXT | Correlation ID matching TxIntent.id; "system" for non-transaction events (e.g. skill registration) |
event | TEXT | Event type name (see Event Type Catalog below) |
data | TEXT | JSON-serialized payload with intentId plus event-specific fields |
created_at | DATETIME | SQLite CURRENT_TIMESTAMP default (ISO 8601 string) |
rate_limit_events table
| Column | Type | Description |
|---|---|---|
wallet_address | TEXT | Ethereum address (checksummed or lowercase) |
timestamp | INTEGER | Unix epoch in milliseconds when the tick was recorded |
Indexes
All four indexes are created at startup viaCREATE INDEX IF NOT EXISTS:
| Index Name | Table | Columns | Purpose |
|---|---|---|---|
idx_intent_id | audit_events | intent_id | Fast lookup of all events for a given transaction |
idx_event | audit_events | event | Filter by event type (e.g. find all denials) |
idx_timestamp | audit_events | timestamp | Chronological ordering for recent-events queries |
idx_rate_wallet_ts | rate_limit_events | wallet_address, timestamp | Sliding-window count per wallet |
Event type catalog
The following audit events are emitted across the ISCL codebase. Each event is logged viaauditTrace.log(eventName, { intentId, ...fields }).
Transaction pipeline events (tx.ts)
| Event Name | When Emitted | Key Data Fields |
|---|---|---|
policy_evaluated | After PolicyEngine evaluates a TxIntent in /v1/tx/build | intentId, decision (“allow”|“deny”|“require_approval”), reasons |
tx_built | After buildFromIntent() successfully builds a BuildPlan | intentId, txRequestHash, description |
preflight_completed | After PreflightService simulates the transaction in /v1/tx/preflight | intentId, simulationSuccess, riskScore, gasEstimate |
tx_broadcast | After successful sendRawTransaction via RPC | intentId, txHash |
broadcast_failed | When sendRawTransaction throws an error | intentId, txHash, error |
Approval events (approval-service.ts, tx.ts)
| Event Name | When Emitted | Key Data Fields |
|---|---|---|
approve_request_created | When /v1/tx/approve-request creates an approval prompt | intentId, decision, riskScore |
approval_granted | When user confirms the approval prompt (CLI or web) | intentId, action, tokenId, riskScore |
approval_rejected | When user declines the approval prompt | intentId, action, reason (“user_declined”) |
web_approval_decided | When a web UI decision is submitted via /v1/approvals/:requestId/decide | intentId, requestId, approved (boolean), action |
Signing events (wallet-service.ts)
| Event Name | When Emitted | Key Data Fields |
|---|---|---|
signature_created | After successful transaction signing | intentId, txRequestHash, signerAddress, txHash |
signing_denied | When signing is refused for any reason | intentId, reason (one of: "missing_policy_decision", "policy_deny", "missing_approval_token", "invalid_approval_token", "key_locked"), plus context fields |
Skill registry events (skills.ts)
| Event Name | When Emitted | Key Data Fields |
|---|---|---|
skill_registered | After successful skill manifest registration | intentId (“system”), skillName, manifestHash, publisherAddress |
skill_registration_failed | When registration fails (duplicate, validation, scan) | intentId (“system”), skillName, reason |
skill_revoked | When a skill is deleted via DELETE /v1/skills/:name | intentId (“system”), skillName |
Complete event flow example
A typical successful transaction produces this sequence of audit events: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).
Programmatic access
TheAuditTraceService exposes two read methods:
AuditEvent[]:
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:
Incident investigation
When investigating a suspicious or failed transaction, follow these steps to reconstruct the full picture.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:
Check the policy decision
Look for Common deny reasons include:
policy_evaluated events. If the decision was "deny", the reasons array explains why:value_exceeds_max— transfer value exceedsmaxValueWeiin policy configapproval_exceeds_max— ERC-20 approval amount exceedsmaxApprovalAmountchain_not_allowed—chainIdnot inallowedChainsrecipient_not_in_allowlist— destination not inrecipientAllowlistcontract_not_in_allowlist— contract not incontractAllowlistrisk_score_too_high— preflight risk score exceedsmaxRiskScorerate_limit_exceeded— wallet exceededmaxTxPerHour
Check the approval flow
For transactions requiring approval, look at the approval events:
approve_request_created— approval prompt was generated.approval_grantedorapproval_rejected— CLI/programmatic approval outcome.web_approval_decided— web UI approval outcome (includesrequestIdandapprovedboolean).
If there is an
approve_request_created but no subsequent grant/reject, the approval request likely expired (TTL is 300 seconds by default).Verify signing and broadcast
signature_createdconfirms the transaction was signed. ChecksignerAddressandtxRequestHash.signing_deniedmeans the WalletService refused. Thereasonfield indicates why: missing policy decision, invalid approval token, policy deny, or locked key.tx_broadcastconfirms the signed transaction was sent to the network.broadcast_failedindicates an RPC-level failure. Theerrorfield contains the RPC error message.
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 dedicatedrate_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.
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 themaxTxPerHour field in PolicyConfig:
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.
Compliance and retention
Append-only guarantee
TheAuditTraceService 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
- File copy
- SQLite backup API
Copy the database file while the application is running. SQLite WAL mode ensures read consistency. Copy all three files:
audit.dbaudit.db-walaudit.db-shm
Export to JSONL
For external analysis or archival, export the audit trail as JSON lines: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:
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)
- ~600 audit events/day (~200 KB/day)
- ~100 rate-limit ticks/day (~6 KB/day)
Next steps
- Observability — Structured logging, health monitoring, and metrics
- Incident Runbook — Symptom-indexed diagnosis guide
- Configuration Reference — All configurable parameters