Chronicle.Provider.Ecto (chronicle v0.1.0)

Copy Markdown

Stores audit groups and events in an Ecto repository.

Required option:

Optional options:

  • :events_table - defaults to "audit_events"
  • :ledger_heads_table - defaults to "audit_ledger_heads"
  • :ledger_entries_table - defaults to "audit_ledger_entries"
  • :prefix - database prefix/schema
  • :chunk_size - event insert batch size, defaults to 500
  • :integrity - HMAC ledger options; required

A group and its events share the record table: the group is a row with kind: "group" carrying the unit's outcome, timing, and child count, and its children reference it by group_id. All of them are inserted in one repository transaction and covered by one ledger entry. If the caller is already inside a transaction on the same repo, Ecto uses that transaction. Integrity configuration is fail-closed: omit it and the write fails. The Ecto provider has no unsigned mode.

The signature covers the row, not the intention

Every write builds its ledger entry from content_row/1 — a projection of the row as it is about to be stored — and does so before the insert. The ordering matters more than it looks. Signing the caller's original values and then storing a normalized version would produce a signature that verifies against something the database does not contain, and the mismatch would surface years later as an unexplained tamper alert.

Which columns that projection covers is decided in exactly one place, Chronicle.Ecto.Schema.Event.content_fields/0, which derives the list from the schema's own fields rather than restating them. A new column is signed because it exists, not because someone remembered to add it twice, and the adversarial suite generates a tamper case per column from the same list — so new columns arrive with coverage already attached.

The one deliberate exclusion is inserted_at, which is why it is subtracted explicitly and visibly there rather than simply left out.

A group is signed as a unit

One ledger entry covers a group and all its children. The root row carries the child count, and it is signed, so adding or removing a child is detectable even though the children themselves have no entries of their own. This is why the whole group is written in one transaction: a partially written group is a group whose signed count does not match reality.