Skip to content

RFC: Single Append-Only Audit Log #2134

Description

@crodas

Summary

This RFC proposes adding one append-only event_log table while keeping the existing tables as the source of current state.

The existing tables continue to answer: “What is true now?”

The append-only log answers: “How did the system get here?”

This does not replace existing tables, does not change the normal read path, and does not require every domain table to become append-only. It only adds a shared audit trail for meaningful state changes.

Proposal

Add a single table that records changes across all tracked entities.

CREATE TABLE event_log (
    change_id   INTEGER PRIMARY KEY,
    table_name  TEXT NOT NULL,
    entity_id   TEXT NOT NULL,
    change_type TEXT NOT NULL,
    data        TEXT NOT NULL
);

CREATE INDEX idx_event_log_entity
    ON event_log(table_name, entity_id, change_id);

Conceptually, each row is:

struct EventLogRow {
    change_id: i64,
    table_name: String,
    entity_id: String,
    change_type: ChangeType,
    data: JsonSnapshot,
}

enum ChangeType {
    InitialMigration,
    Created,
    Updated,
    Deleted,
}

The data field stores a serialized snapshot of the entity at the time of the event. A Created event stores the inserted state. An Updated event stores the resulting state after the update. A Deleted event stores the final known state before deletion. An InitialMigration event stores the baseline state found during migration.

Why Append-Only?

Mutable tables are efficient for current state, but they do not explain history. After an update, the previous value may be gone. After a delete, the row may disappear entirely.

The append-only log preserves every meaningful transition. It makes the database easier to audit, debug, replay, and reconcile. It allows the system to inspect the lifecycle of an entity without changing the existing operational model.

The current tables remain optimized for normal work. The event log is optimized for historical explanation.

Why a Single Log Table?

A single shared event_log is simpler than creating one history table per entity type.

Instead of maintaining many separate history tables, all events use the same structure: change_id, table_name, entity_id, change_type, and data.

This gives the system one uniform audit model. The same query can retrieve the history of any tracked entity:

SELECT *
FROM event_log
WHERE table_name = :table_name
  AND entity_id = :entity_id
ORDER BY change_id;

change_id

The change_id is an application-generated i64. It should not use database autoincrement.

The proposed layout is:

1 bit:  always 0
41 bits: timestamp in milliseconds since custom epoch
22 bits: hash(changeset)

Conceptually:

change_id =
    0 | timestamp_ms_since_custom_epoch | hash(changeset)

The highest bit is always 0 so the value remains a positive signed i64.

The timestamp is stored in the high-order bits, so IDs remain naturally sortable by time. Because event time is embedded directly in change_id, a separate changed_at column is unnecessary.

The final 22 bits come from hash(changeset). The changeset is the stable event content used to describe what changed.

Conceptually, the changeset includes:

struct ChangeSet {
    table_name: String,
    entity_id: String,
    change_type: ChangeType,
    data: JsonSnapshot,
}

The hash should be derived from the canonical serialized form of the changeset. The change_id itself should not be included in the hash input.

This gives the ID two useful properties:

First, it is time-sortable because the timestamp is in the high-order bits.

Second, it is content-aware because the lower bits are derived from the event content.

Other systems often use a Snowflake-style layout with worker ID and increment/sequence bits. That approach is useful when many IDs may be generated in the same millisecond or when there are multiple writers.

This RFC intentionally uses the lower bits for hash(changeset) instead. The goal is to make change_id not only time-sortable, but also connected to the event payload.

Notes on Collisions

The 22-bit hash is a lightweight content-derived component. It helps detect obviously mismatched IDs and changesets, but it should not be treated as a full cryptographic proof.

Collisions are possible because 22 bits is a small hash space. The database should still enforce uniqueness through the primary key on change_id.

If stronger guarantees are needed later, the same general model can be extended with a separate full payload_hash, a keyed MAC, a hash chain, or a Snowflake-style worker/sequence layout.

Invariants

The existing tables remain the current-state tables.

The event log is append-only.

Every tracked create, update, or delete should append one event in the same transaction as the state change.

Events are ordered by change_id, which primarily orders by the embedded millisecond timestamp.

There is no separate changed_at; event time is derived from change_id.

The database should enforce uniqueness with the primary key on change_id.

Conclusion

This proposal adds auditability without replacing the existing storage model.

The system keeps fast current-state tables and gains a durable append-only history of state changes.

The central idea is simple: keep the existing tables, append every meaningful change to one shared log, and identify each event with an application-generated i64 change_id.

The proposed change_id uses one fixed zero bit, 41 bits of millisecond timestamp, and 22 bits derived from hash(changeset). This keeps the ID positive, sortable by time, and lightly tied to the event content.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions