Skip to content

[engine] Append-only SHA-256 hash chaining for orders, transactions and attestations #721

Description

@eaitbrahim

Context (verified 2026-09-04, on main at the #720 merge)

#703's activity export ships with a row_hash column whose every cell reads NOT RECORDED.

That is honest, and it is the whole of what can be said today. The issue asked the export to be "tamper-evident by construction, not by claim" — and none of the four stores the timeline merges hashes its rows:

  • orders — no hash column
  • transactions — no hash column
  • asset_attestations — no hash column
  • instrument_attestations — no hash column

(Checked directly against keel/data/db.py; the count is zero across all four.)

The only hash-chained store in this codebase is the research trials ledger, keel/research/ledger.py: hash-chained JSONL where each row carries prev_hash (the previous row's row_hash) and its own row_hash, with verify_chain() reporting every break rather than raising on the first. It records experiments, not trading activity, and it was deliberately not folded into the activity feed — blending research trials into a trading audit trail to borrow their hashes would be provenance laundering, which is the opposite of what the export is for.

So the export currently says NOT RECORDED on every row rather than leaving the column blank. Blank invites the reader to assume the check passed; the point of the column is that an auditor can see there is nothing to check yet.

Scope

Give the three DATABASE stores the same chaining discipline research/ledger.py already proves out:

  1. A hash per row, chained. row_hash = SHA-256(canonical-JSON(row fields) + prev_hash), with prev_hash the previous row's hash in that table's own chain. Editing or deleting any row breaks verification for that row and every row after it — which is the property that makes the export evidence rather than a report.
  2. The canonical form is the hard part and belongs in one place. keel/research/ledger.py already decides what canonical JSON means here (key order, Decimal rendering, absent-vs-null). Reuse that function rather than writing a second one: two canonicalisations that disagree produce two hashes for one row, and the disagreement surfaces as a chain that "cannot be verified" long after the rows were written.
  3. Append-only in practice, not only in intent. orders rows are UPDATED today (update_order sets status, fills, fees as a venue reports them). A chain over a mutable row is a chain that breaks on every legitimate update, so this needs a decision the issue cannot pre-empt: either hash the row at INSERT and accept that later updates are outside the chain (and say so in the export), or write an append-only audit-event table alongside and chain THAT. The second is more work and is the one that actually delivers tamper-evidence for a mutable book.
  4. verify_chain equivalents per table, reporting every break rather than raising, so keel doctor and the export can both state chain status rather than assert it.

Refusals

  • No backfill. Rows already written were written without a chain; computing hashes for them now would produce a chain that verifies and proves nothing, which is worse than an honest gap because it looks like evidence.
  • No borrowing the trials ledger's hashes. Different domain, different records.
  • No hash computed at export time. That would be this codebase attesting to its own output at the moment of export — it detects nothing, because anything that altered a row before the export is inside the hash.

Then

TimelineRow.row_hash reads the recorded hash instead of HASH_NOT_RECORDED, and the export gains a chain-status column. Both the report shape and the CSV header were built for that swap.

Acceptance

  • A newly inserted row in each of the three stores carries a row_hash chained to its predecessor.
  • Altering a row in place makes that row and every later row fail verification.
  • Rows written before the bump verify as "not chained" rather than as broken — an honest gap, not a false alarm.
  • The export's row_hash column carries real hashes for chained rows and NOT RECORDED for the rest, in the same file.

Split out of #703. Fourth of this shape after #715 (order provenance and idempotency), #718 (attestation windows) and #719 (the balance pair): a surface built and waiting on a record the engine does not yet keep.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    complianceShariah/compliance screening or policy (Compliance & rails)featureNew capability (groups under Features)

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions