The hash chain¶
Each journal entry's hash covers the previous entry's hash, so editing any entry invalidates every entry after it.
entry 1 hash = SHA-256( ZERO_HASH || fields of entry 1 )
entry 2 hash = SHA-256( hash₁ || fields of entry 2 )
entry 3 hash = SHA-256( hash₂ || fields of entry 3 )
Fields covered: previous hash, saga id, seq, tool name, phase, input, payload, timestamp. The first entry of each saga chains from a zero hash.
Why fields are length-prefixed¶
The obvious encoding — join the fields with a delimiter — is wrong here, and subtly so.
input and payload hold arbitrary tool arguments and results. They can contain
any character, including whatever delimiter you picked. With a plain | join:
Two different entries, one preimage. An attacker who controls tool input could craft an entry whose hash collides with a different, legitimate-looking one — without breaking SHA-256 at all.
Sagacity prefixes every field with its UTF-8 byte length:
The encoding is now unambiguous: no arrangement of field contents can be parsed
two ways, so no arrangement can forge another entry's hash. Byte length rather
than character length, because multi-byte characters would otherwise reintroduce
the ambiguity — "€uro" and "€"/"uro" differ in bytes even where character
counts mislead.
Why timestamps are truncated¶
Postgres TIMESTAMP holds microseconds. Instant.now() may carry nanoseconds
depending on platform. Hashing the nanosecond value and then storing a truncated
one means the value read back never rehashes to the stored hash — verification
would fail for every persisted saga, and would look exactly like tampering.
Timestamps are truncated to microseconds before hashing and before writing, and
rendered in a fixed-width canonical form rather than Instant.toString(), whose
output width varies with trailing-zero suppression.
What SHA-256 is doing here¶
It provides tamper evidence, not tamper prevention. The chain makes it infeasible to alter an entry and produce a matching hash without recomputing everything downstream.
That defends against exactly one adversary: someone who can edit the database but does not bother to — or cannot — recompute the chain. A careless insider, an application-level SQL injection, a corrupted restore.
It does not defend against someone who can run Sagacity's own hashing over a rewritten history. Nor does it detect truncation: deleting the last N entries leaves a shorter, perfectly valid chain, because nothing inside a journal can prove entries once existed.
Closing both gaps requires anchoring the head hash outside the database — see the threat model.
Would a stronger algorithm help?¶
No. The weaknesses above are structural, not cryptographic. SHA-256 has no known
practical collision or preimage attack, and swapping it for SHA-3 or BLAKE3 would
change nothing about truncation or wholesale rewriting. Effort is better spent on
anchoring and on REVOKE UPDATE, DELETE.