Audit trail
Hash-chained event log, SQLite WORM sink, daily anchors. Immutable record every regulated desk needs.
SEC Rule 204-2 requires an RIA to retain trading records for at least five years, the first two at the advisor’s office, in a form that cannot be altered. Broker-dealers under Rule 17a-4 get six years and explicit WORM storage. horizon.audit is the substrate: an append-only, hash-chained event log that any Horizon module can emit into.
The model
AuditEvent (immutable frozen dataclass)
├── event_id ULID
├── sequence monotonic per log
├── timestamp tz-aware
├── category AuditCategory enum (order.submitted, risk.decision, ...)
├── severity AuditSeverity enum
├── actor "strategy:my_strat" | "user:alice" | "system"
├── account_id / client_id / venue_name / correlation_id
├── market_id / order_id / client_order_id
├── message human-readable
├── payload free-form dict
├── prev_hash SHA-256 of the preceding event
└── hash SHA-256 of this event with hash="" stripped
Events are never updated or deleted. The SQLite sink enforces this with triggers. The Protocol has no update or delete methods.
Quickest start
from horizon.audit import AuditLog, AuditCategory, SQLiteSink
log = AuditLog(sink=SQLiteSink("audit.db"))
log.record(
AuditCategory.OrderSubmitted,
account_id="acc_jane_taxable",
order_id="alpaca_ord_abc",
client_order_id="hzn_a1b2...",
market_id="AAPL",
message="limit buy 100 AAPL @ 180.00",
payload={"side": "buy", "qty": 100, "price": 180.0, "strategy_id": "bollinger_mean_rev"},
)
AuditLog generates the event, assigns a sequence number, links it into the chain, and persists.
Categories
AuditCategory is a stable taxonomy. Names are permanent (changing them would break replay of historical logs). Sample:
| Category | When |
|---|---|
SystemStart / SystemStop | Process lifecycle |
ConfigChange | Runtime config mutation |
SecretAccess | Every secret read (L1) |
OrderSubmitted / OrderAcknowledged | Order lifecycle |
OrderFilled / OrderPartiallyFilled | Fills from the venue |
OrderCanceled / OrderAmended / OrderRejected | State transitions |
RiskDecision | Every check_order verdict with the layer. See Risk layers. |
BuyingPowerCheck / PDTCheck / LocateApproved | Pre-trade control outcomes |
IPSCheck / RestrictionCheck / SuitabilityRecord | Per-account policy checks |
BestExRecord | NBBO at submit and fill, price improvement, venue. See Best execution. |
PositionOpened / PositionClosed / PositionAdjusted | Ledger state changes |
WashSaleDetected / LotClosed | Tax-lot accounting. See Tax lots. |
KillSwitchFired / StopLossFired / MarginWarning | Watchdog fires. See Kill switch. |
FeedConnected / FeedDisconnected / FeedStale / FeedGap | Live feed health. See Live feeds. |
ReconcileStart / ReconcileMismatch / ReconcileResolved | Broker-truth reconciliation |
Unknown categories are rejected at construction. Add new ones by appending to the enum.
Hash chain
Each event’s hash is computed over canonical JSON with the hash field stripped. prev_hash is the preceding event’s hash. Break any intermediate event and every downstream hash stops matching.
from horizon.audit import AuditChain
events = list(log._sink.read_range())
broken = AuditChain.verify(events)
# broken is a list of sequence numbers where verification fails.
# Empty list = log is pristine since the genesis event.
Verification is one SHA-256 per event. Run at shutdown, start-of-day, or nightly.
Daily anchor
A hash chain alone cannot stop an insider with write access: they can recompute the whole chain after replacing an event. Publish a daily anchor (SHA-256(last_hash || date)) to a location outside the firm’s control: an immutable email archive, a public blockchain, a notary service, a sealed compliance envelope.
from datetime import date
from horizon.audit import anchor_hash
today_anchor = anchor_hash(log.last_hash, date.today())
print(today_anchor)
# "8f2a4b1c...e9"
If a later auditor recomputes the anchor from the stored log and it matches the externalized value, the period between anchors is verified.
Sinks
AuditSink is append-only storage:
class AuditSink(Protocol):
def write(self, event: AuditEvent) -> None: ...
def read_last(self) -> AuditEvent | None: ...
def read_range(self, start_seq=None, end_seq=None) -> Iterator[AuditEvent]: ...
def close(self) -> None: ...
InMemorySink
Tests only. Loses everything on process exit.
from horizon.audit import AuditLog, InMemorySink
log = AuditLog(sink=InMemorySink())
SQLiteSink
The default for solo-advisor deployment. WAL mode, PRAGMA synchronous=FULL (durability over throughput). Triggers raise IntegrityError on UPDATE or DELETE.
from horizon.audit import AuditLog, SQLiteSink
log = AuditLog(sink=SQLiteSink("/var/lib/horizon/audit.db"))
Reopening resumes the chain from the last persisted event. Sequence numbers continue; prev_hash of the next event equals the last persisted hash.
log = AuditLog(sink=SQLiteSink("/var/lib/horizon/audit.db"))
log.sequence # picks up from the last event
log.last_hash # the hash of that event
For WORM compliance pair the file with:
- A filesystem that supports WORM (S3 Object Lock, Glacier Vault Lock, WORM-configured ZFS, Linux immutable attribute).
- A DB role without UPDATE / DELETE privileges.
- Nightly copy to an external immutable bucket.
L1 and L2
PostgresSink. Multi-writer. Same Protocol.S3ObjectLockSink. WORM at the object store. Retention enforced by the bucket.GlacierVaultLockSink. Archival tier. Compliant with SEC WORM interpretation.
Observers
Subscribe any function to receive every event as it is logged. Observer failures do not break the write path.
def alert_on_kill_switch(event):
if event.category == AuditCategory.KillSwitchFired:
slack_post(f"KILL SWITCH FIRED: {event.message}")
log.subscribe(alert_on_kill_switch)
Replaying history
Every state-changing action emits an event. Reconstruct any point-in-time view by replaying the log:
cutoff = datetime(2026, 3, 14, 14, 30, tzinfo=timezone.utc)
ledger_snapshot = rebuild_ledger(
events=[e for e in sink.read_range() if e.timestamp <= cutoff
and e.account_id == "acc_jane_taxable"]
)
See Recovery for the shipped ledger-rebuild path.
What this answers for a regulator
- “Show every order
cli_jane’s strategy generated on 2026-03-14, in submission order, with the risk decision for each.” - “Prove that order
alpaca_ord_abcreceived a best-execution evaluation.” - “Reconstruct the firm-wide position as of 16:00 ET on 2026-03-14.”
- “Show that the IPS in effect when this order was placed permitted crypto.”
- “Demonstrate no one tampered with records between 2026-01-01 and 2026-12-31.” (Chain verification plus externalized anchors.)
A Rule 204-2 exam asks these questions.
What this does not cover
- Encryption. The chain is tamper-evident, not confidential. Encrypt the sink separately (envelope encryption with KMS) before PII lands in it. L1.
- Wall-clock proof. The chain proves ordering, not wall time. Pair with RFC 3161 timestamps from a Time-Stamping Authority for stronger attestation.
- Immutable storage. SQLite triggers are a second line of defense. WORM storage underneath is required.
- Turnkey compliance. Form ADV, BCP, Code of Ethics, and client-statement generators remain counsel’s and the firm’s work product.