Ledger audit hash timezone fix¶
Date: 2026-06-02 Type: Hotfix Domain: app/graphql/ledger_audit/ Related feature page: ledger-audit.md
Problem / motivation¶
ledgerAuditChainVerify returned isValid: false for every tenant whose
timezone is not UTC (e.g. all Panama tenants on America/Panama, −05:00),
even though the audit data is completely intact.
Root cause is an asymmetric timestamp serialization between the writer and the verifier:
LedgerAuditService.recordhashedat = self.context.now.isoformat(), wherecontext.nowispendulum.now(tz=self.tz_info)— a tenant-local, timezone-aware datetime. For Panama that string is e.g.2026-05-26T09:13:01.397875-05:00.- The
atcolumn isTIMESTAMP(timezone=True). Postgres returns it normalized to UTC, soLedgerAuditService.verify_chainrecomputed the hash withrow.at.isoformat()=2026-05-26T14:13:01.397875+00:00.
Same instant, different string → different SHA-256. The genesis row already fails, so the whole chain reports invalid.
This was proven by recomputing the seq-1 genesis hash directly from production
data: only the −05:00 local-time string reproduces the stored row_hash
(e46d6dce…); the UTC string does not.
Two secondary weaknesses were found while diagnosing and are folded into this hotfix because the rehash migration is the natural moment to address them:
- Tamper-coverage gap. The hash material was only
prev_hash | payload | at | actor_id.operation,entry_id,line_idandsequence_numberwere not covered, so those columns could be altered without breaking the chain — defeating the point of a tamper-evident log. - No tests. The module shipped with zero tests, and
tz_infodefaults to"UTC", so any UTC-based fixture has writer == verifier and never exercises the bug.
In scope¶
- Make
athashing timezone-independent: canonicalize to UTC ISO-8601 on both the write and verify paths, by hashing thedatetimeitself (not a pre-formatted string) so the two paths cannot drift. - Harden the hash to cover
sequence_number,entry_id,line_id,operationin addition toprev_hash | payload | at | actor_id. - A data migration that recomputes
prev_hash+row_hashfor all existingledger_audit_logsrows, insequence_numberorder, using the corrected canonicalization (safe — the underlying data is verified intact). - Tests for
LedgerAuditService.record/verify_chain, including a non-UTC tenant regression that fails before the fix and passes after. - Update the existing
ledger-audit.mdhash-formula description. - Version bump + changelog.
Out of scope¶
- The concurrency race on
sequence_number(two concurrentrecordcalls read the samelast_row). Tracked as a future addition below. - Reporting the resolved
end_sequenceback in the response when the caller omits it. Future addition. - Any change to the GraphQL surface — types, queries, and field names are unchanged.
Data model changes¶
No schema changes. ledger_audit_logs columns are unchanged. This ships one
data-only migration:
- Revision
rehash_ledger_audit_logs(down_revision = add_position_to_users):upgrade()walksledger_audit_logsordered bysequence_number, recomputes each row'sprev_hash(previous row's newrow_hash,NULLfor the first) androw_hashunder the new formula, and writes them back.downgrade()is a no-op by necessity (the pre-fix hashes were timezone-dependent and not reconstructable post-UTC-normalization); this is documented in the migration docstring and is safe because hashes are derived data, fully rebuildable by re-runningupgrade().
GraphQL surface¶
Unchanged:
ledgerEntryHistory(entryId: UUID!): [LedgerAuditLog!]!
ledgerAuditChainVerify(startSequence: Int = 1, endSequence: Int = null): LedgerAuditChainVerification!
RBAC¶
No change. Both queries continue to require Path.AUDIT_LOG.
Background tasks / cron¶
None.
Frontend contract¶
No change to wire up. ledgerAuditChainVerify simply starts returning
isValid: true for non-UTC tenants once the migration has run. The
"Audit integrity" badge described in ledger-audit.md will go
green. No query, field, or type changed.
What's being implemented¶
- Changed service — LedgerAuditService:
_compute_hash(...)now accepts theatdatetimedirectly and normalizes it to UTC ISO-8601 internally; new positional memberssequence_number,entry_id,line_id,operationadded to the material.record(...)passesnow(the datetime) and the new identity fields.verify_chain(...)passesrow.atand the new identity fields.- New migration —
alembic/versions/20260602_rehash_ledger_audit_logs.py(revision = rehash_ledger_audit_logs). - New tests —
tests/graphql/ledger_audit/test_ledger_audit_service.pycovering: record→verify symmetry on a non-UTC (America/Panama) tenant; a clean multi-row chain verifiesTrue; a tamperedpayload,operation,entry_id, and broken link each verifyFalse. - Docs — corrected hash formula in
ledger-audit.md; this page wired into
mkdocs.yml.
Hash material (new)¶
sha256(
(prev_hash or "") | sequence_number | entry_id | (line_id or "") |
operation_int | canonical_json(payload) | at_utc_isoformat | (actor_id or "")
)
where at_utc_isoformat = at.astimezone(UTC).isoformat() and canonical_json
is json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).
What shipped¶
Everything in the in-scope list landed:
LedgerAuditService._compute_hashnow takes theatdatetimeand normalizes it to UTC internally, and the material coversprev_hash | sequence_number | entry_id | line_id | operation | payload | at_utc | actor_id.recordandverify_chainwere updated to match.- Migration
rehash_ledger_audit_logsrebuildsprev_hash+row_hashfor every existing row insequence_numberorder. Verified: the migration's inlined hash is byte-identical to the service hash across the value types the driver returns (raw-int vs enumoperation,UUIDvsstrids). tests/graphql/ledger_audit/test_ledger_audit_service.py— 12 tests, all green: tz-stability, identity-field coverage, a 5-row Panama record→verify round-trip, an all-timezones parametrization, and tamper detection forpayload/operation/entry_id/ a broken link.ledger-audit.mdhash formula corrected; this page wired intomkdocs.yml.
task all is green (lint, typecheck, file-length, both schema exports) and the
ledger-audit test module passes 12/12.
Future additions¶
- Sequence-number concurrency guard —
recordderives the next sequence fromrepository.last_row(); two concurrent posts collide on thesequence_number/row_hashunique constraints. Deferred: needs a row lock or an INSERT-with-retry, out of scope for a hash-correctness hotfix. - Report resolved
end_sequence— when the caller omitsendSequence, the response still echoesnullinstead of the last sequence actually verified. Cosmetic; deferred.