Skip to content

AuditChainHasher

Namespace AgentPrism · Assembly AgentPrism.Core.dll

Computes the hash chain link for an audit trail entry. The single place the canonical form is defined; both the write path and the verify path call it, so the two can never drift apart.

public static class AuditChainHasher

objectAuditChainHasher

object.GetType(), object.MemberwiseClone(), object.ToString(), object.Equals(object?), object.Equals(object?, object?), object.ReferenceEquals(object?, object?), object.GetHashCode()

The canonical form is a fixed-shape, hand-written JSON object — fields are written in a FIXED order and the writer is not System.Text.Json’s default serializer, so there is no property-ordering ambiguity to worry about. The field order is part of the contract: changing it changes every hash computed afterward and makes an already-written chain unverifiable.

createdAt is rounded down to microsecond precision before it is hashed. PostgreSQL’s timestamptz stores only microsecond precision (it drops the last digit of a.NET 100ns tick); without this rounding, a value hashed with full tick precision at write time would never match the value read back from PostgreSQL at verify time, and every entry would misreport as AuditChainStatus.Broken. SQL Server and SQLite both preserve full tick precision, so rounding is harmless there — it is applied unconditionally so the same canonical form works on all three providers.

ComputeHash(string?, string, string?, string, string, string?, string?, DateTimeOffset)

Section titled “ ComputeHash(string?, string, string?, string, string, string?, string?, DateTimeOffset)”

Computes the hash of an audit entry.

public static string ComputeHash(string? previousHash, string tenantId, string? actor, string action, string entity, string? before, string? after, DateTimeOffset createdAt)

previousHash string?

The hash of the previous entry of the same tenant; null for the first entry.

tenantId string

The tenant id.

actor string?

The actor.

action string

The action name.

entity string

The affected entity.

before string?

The state before the change, as JSON text.

after string?

The state after the change, as JSON text.

createdAt DateTimeOffset

The time the record was written.

string

The hash, as a lowercase 64-character hex string (SHA-256).

Rounds a timestamp DOWN to microsecond precision — the precision PostgreSQL’s timestamptz actually stores.

public static DateTimeOffset TruncateToMicroseconds(DateTimeOffset value)

value DateTimeOffset

The timestamp.

DateTimeOffset

The rounded timestamp, in UTC.

The SQL write path (SqlAuditLog.WriteAsync) truncates AuditEntry.CreatedAt to THIS value BEFORE it is hashed AND before it is written to the created_at column. Truncating only for the hash (and leaving the column at full precision) is not enough: whether Npgsql’s client-side conversion truncates or ROUNDS a sub-microsecond remainder is an implementation detail this code must not depend on — measured, it does not always agree with a simple truncation, and every entry misreported as AuditChainStatus.Broken as a result. By pre-truncating the value that is ACTUALLY stored, there is no remainder left for the database to round any way at all.