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:
- 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.
- 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.
- 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.
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.
Context (verified 2026-09-04, on
mainat the #720 merge)#703's activity export ships with a
row_hashcolumn whose every cell readsNOT 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 columntransactions— no hash columnasset_attestations— no hash columninstrument_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 carriesprev_hash(the previous row'srow_hash) and its ownrow_hash, withverify_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 RECORDEDon 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.pyalready proves out:row_hash = SHA-256(canonical-JSON(row fields) + prev_hash), withprev_hashthe 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.keel/research/ledger.pyalready 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.ordersrows are UPDATED today (update_ordersets 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.verify_chainequivalents per table, reporting every break rather than raising, sokeel doctorand the export can both state chain status rather than assert it.Refusals
Then
TimelineRow.row_hashreads the recorded hash instead ofHASH_NOT_RECORDED, and the export gains a chain-status column. Both the report shape and the CSV header were built for that swap.Acceptance
row_hashchained to its predecessor.row_hashcolumn carries real hashes for chained rows andNOT RECORDEDfor 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.