The hash chain and append-only enforcement
The integrity claim is the product, so this page states the mechanism precisely, including its current limits. You should be able to verify every sentence here against your own data.
Append-only, enforced by the database#
Three triggers sit on the ledger table and reject every update, delete and truncate. They are database triggers, not application checks, so they apply to any connection: the Governax application, a direct psql session, a migration, an ops one-liner. The attempted operation fails with:
ERROR: governance_events is append-only. Operation UPDATE is forbidden.Two supporting constraints back this up. An organisation or entity that has ledger rows cannot be deleted, so history cannot be removed by deleting its parent. And a row cannot be written at all unless it carries a full-length hash.
Insert is the only permitted operation, and even an insert cannot choose its own sequence number or hashes. Those are overwritten by the database regardless of what the application sends.
How the chain is built#
When an event is inserted, the database finds the entity's current last event, assigns the next sequence number, copies that last event's hash into the new row as prev_hash, and computes the new row's own hash over a recipe that includes prev_hash.
entity_seq 1 prev_hash null row_hash A
entity_seq 2 prev_hash A row_hash B
entity_seq 3 prev_hash B row_hash C
^ ^
| +-- computed over the row's contents
| AND prev_hash
+-- copied from the previous row's row_hashBecause each hash feeds the next, altering any covered field on event 2 changes its hash, which no longer matches what event 3 recorded as its predecessor, and every link after it fails. Each entity has its own chain starting at sequence 1 with a null prev_hash; chains never interleave.
Exactly what is hashed#
SHA-256 over the concatenation of six inputs, in this order:
row_hash = sha256(
COALESCE(prev_hash, ''::bytea)
|| convert_to(entity_id, 'UTF8')
|| convert_to(source_tool, 'UTF8')
|| convert_to(event_type, 'UTF8')
|| convert_to(occurred_at::text, 'UTF8')
|| convert_to(payload::text, 'UTF8')
)The payload is hashed in its canonical stored form, so key ordering and whitespace are normalised before hashing and cannot be used to produce two different hashes for the same content.
What the hash does not cover#
The following fields are stored on the event but are not inputs to the hash:
actor_external_idandactor_emailsubject_external_idandsubject_emailentity_seq,org_id,source_event_id,idandingested_at
What this protects against#
That last row is worth dwelling on. A hash chain proves the integrity of what it contains. It cannot prove completeness, because an event that was never written leaves no gap to find. Completeness comes from the connectors, and the connect-time baseline is what gives you a known starting position to reason from.
Running a check across the whole chain is covered in Verifying the ledger.
Correcting a mistake#
There is no edit, no soft delete and no redaction. If something was recorded wrongly, the correction is a new event appended after it. Both remain visible, which is the point: the record shows what was believed at the time and what was later corrected, in order.