Skip to content

Internals

Schema

The event table, firm_audit_events:

id              autoincrement PK, total event order (strictly monotonic; SQLite AUTOINCREMENT)
action          VARCHAR(255), not null — what happened
subject_type    VARCHAR(255) — polymorphic target type
subject_id      VARCHAR(255) — polymorphic target id
subject_label   VARCHAR(255) — display name captured at event time (display-only)
actor_type      VARCHAR(255) — polymorphic actor type
actor_id        VARCHAR(255) — polymorphic actor id
actor_label     VARCHAR(255) — display name captured at event time (display-only)
correlation_id  VARCHAR(255) — groups events from one request/transaction
data            TEXT — JSON-encoded free-form payload
changes         TEXT — JSON-encoded before/after diff
context         TEXT — JSON-encoded request metadata
created_at      timestamp — when the event was recorded
entry_id        VARCHAR(26) — client ULID, unique; identity + anti-replay (tamper-evidence)
row_mac         VARCHAR(64) — hex HMAC over the canonical row (tamper-evidence)
key_id          VARCHAR(16) — which key signed the row, for rotation (tamper-evidence)

The last three columns are always present but nullable: they stay NULL until a key is configured, so a no-key deployment behaves exactly as before. Enabling tamper-evidence adds no columns — it only starts populating them, and starts writing the two side tables firm_audit_seals (independent range seals plus the signed activation and floor markers) and firm_audit_verify_status (the single row the verifier upserts, read by the dashboard). Those side tables are detailed in Tamper-evidence › Schema and migration.

Every part of a reference is nullable: an actor/subject may have a type without an id (a role label like cron), an id without a type, or neither (a system event) — see Getting started.

Indexes: (subject_type, subject_id), (actor_type, actor_id), action, correlation_id, created_at — every column history() filters on. subject_label/actor_label are display-only (they preserve a human name after the referenced record is gone) and, like data/changes/context, are not indexed or queryable in SQL; see below.

Append-only, by construction

firm.audit.events.append is the only function in the package that writes a row, and it only ever issues an INSERT. firm.audit.record and AuditLog.record both funnel through it. There is no update, and the only delete anywhere in the package is opt-in, age-based retention (see Retention & querying) — recording never triggers it.

This is an application-level guarantee, not a database constraint: nothing stops raw SQL against firm_audit_events from outside firm-audit. The guarantee is "the module is the sole writer, and it only inserts."

JSON-as-text, not JSONB

data/changes/context are stored as Text holding a JSON string — the same approach firm.queue uses for job arguments — rather than native JSON/JSONB. This keeps the column dialect-uniform (SQLite has no native JSON type) at the cost of SQL-side queryability: you can't filter or index into the payload from SQL. In practice this is rarely a real loss — history() filters on the indexed scalar columns (action, subject, actor, correlation_id, created_at), and those are the fields an audit search actually needs. If you need to query inside payloads routinely, consider also recording the relevant value as its own scalar field, or query the table directly with your dialect's JSON functions on data after parsing it yourself.

A small tagged-object protocol round-trips datetime/date/Decimal/UUID values inside data/changes/context, independent of firm.queue.serialization.

Same-transaction guarantee — the fine print

record(conn, ...) writes on exactly the connection you pass. The atomicity guarantee holds only when firm_audit_events lives in the same database that connection belongs to — pass a connection from a different database and the row is simply written there, untethered from whatever transaction you think it's joining. There's no cheap way to detect this misuse at runtime, so it's a documented contract rather than an enforced one.

Portability types

The id PK uses the Integer variant on SQLite (so it maps to INTEGER PRIMARY KEY/rowid and autoincrements), BigInteger elsewhere. created_at is DATETIME(6) on MySQL for sub-second precision, matching firm.cache and firm.queue.