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.
Summary
This RFC proposes adding one append-only
event_logtable 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.
Conceptually, each row is:
The
datafield stores a serialized snapshot of the entity at the time of the event. ACreatedevent stores the inserted state. AnUpdatedevent stores the resulting state after the update. ADeletedevent stores the final known state before deletion. AnInitialMigrationevent 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_logis 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, anddata.This gives the system one uniform audit model. The same query can retrieve the history of any tracked entity:
change_idThe
change_idis an application-generatedi64. It should not use database autoincrement.The proposed layout is:
Conceptually:
The highest bit is always
0so the value remains a positive signedi64.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 separatechanged_atcolumn 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:
The hash should be derived from the canonical serialized form of the changeset. The
change_iditself 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 makechange_idnot 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 fromchange_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
i64change_id.The proposed
change_iduses one fixed zero bit, 41 bits of millisecond timestamp, and 22 bits derived fromhash(changeset). This keeps the ID positive, sortable by time, and lightly tied to the event content.