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

text
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

python
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:

CategoryWhen
SystemStart / SystemStopProcess lifecycle
ConfigChangeRuntime config mutation
SecretAccessEvery secret read (L1)
OrderSubmitted / OrderAcknowledgedOrder lifecycle
OrderFilled / OrderPartiallyFilledFills from the venue
OrderCanceled / OrderAmended / OrderRejectedState transitions
RiskDecisionEvery check_order verdict with the layer. See Risk layers.
BuyingPowerCheck / PDTCheck / LocateApprovedPre-trade control outcomes
IPSCheck / RestrictionCheck / SuitabilityRecordPer-account policy checks
BestExRecordNBBO at submit and fill, price improvement, venue. See Best execution.
PositionOpened / PositionClosed / PositionAdjustedLedger state changes
WashSaleDetected / LotClosedTax-lot accounting. See Tax lots.
KillSwitchFired / StopLossFired / MarginWarningWatchdog fires. See Kill switch.
FeedConnected / FeedDisconnected / FeedStale / FeedGapLive feed health. See Live feeds.
ReconcileStart / ReconcileMismatch / ReconcileResolvedBroker-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.

python
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.

python
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:

python
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.

python
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.

python
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.

python
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.

python
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:

python
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_abc received 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.