Skip to content

Audit chain 100% and HITL 100%: how two numbers map to mechanisms

Audit-chain completeness at 100% and HITL coverage at 100% are each backed by a mechanism: whether written audit records can be independently recomputed; whether high-risk actions passed a human gate, and whether that gate was the actor themselves or someone else.

Why logs alone do not support accountability

Section titled “Why logs alone do not support accountability”

Ordinary audit records capture actor, time, and content. They help rebuild a timeline during incident response.

Accountability also requires that records can be independently recomputed after write. The runtime that writes logs can usually also change them; a row existing in storage only means it is queryable now, not that it has been unchanged since write.

Audit therefore needs a recomputable structure: the same fields that enter the chain must yield the same digest; if any record is altered, verification of the rest of the chain fails. Post-hoc rewrite and normal append become distinguishable by result.

Each audit record carries two digest fields:

  • prev_hash: digest of the previous record; the chain head uses a fixed genesis value (a zero string of agreed length)
  • hash: computed from prev_hash and this record’s canonical body
hash = sha256(prev_hash + "\n" + canon(body))

body includes tenant, session, event type, payload, time, and other fields that enter the chain. canon reduces the body to a stable, reproducible sequence so the same content does not produce different digests from serialization differences.

Writes append to the chain tail in time order. Verification recomputes in time order: the current record’s prev_hash must equal the previous hash, and the current hash must equal a recompute from the current body. Any step failure stops and returns the break record id.

The external verify API is GET /audit/verify. Example responses:

{ "ok": true, "checked": 50, "broken_at": null }
{ "ok": false, "checked": 12, "broken_at": "aud_…" }

checked is the number of recomputed records; a non-null broken_at points at the break. Deployment acceptance scripts call the same endpoint and require a full chain scan.

Capability boundary: the hash chain constrains rewrite of history after a successful write. If the write path itself is compromised, forged content can still be written with a matching digest. Given a trusted write path, later rewrite of old records is detected. This covers post-write integrity, not anti-forgery at the instant of write.

Operations are layered by external side effects. Only mutating actions (those with external side effects) require audit, and may require approval by rule:

LayerExamplesAuditApproval
ReadRead files, query logsRetainedNo
WriteChange workspace, produce patchesRetainedPer rule
mutatingPush, send email, production change, paymentRequiredForced per rule

Forcing human approval on every read/query hurts usability; allowing push, change, or payment to skip recording leaves gaps in investigation. Gates therefore concentrate on mutating.

Here 100% means mutating paths must land on the chain continuously — intermittent gaps are not allowed. Audit coverage is binary: the path always lands, or there is a blank at a critical moment.

Verification criterion: checked from GET /audit/verify counts whether records already on the chain for the current tenant recompute completely. It is not the business count of all mutating actions that occurred. That count also depends on whether the action entered the landing path. Keep the two numbers separate.

Verify false positives: body shape mismatch

Section titled “Verify false positives: body shape mismatch”

A common false positive: verify reports a broken chain, but storage was not manually altered. The root cause is a mismatch between the body shape used when computing the digest and the shape read back after persistence.

Key order. If the same content serializes with different key order, digests differ and verify misreports tampering. Normalize key order before hashing:

const keys = Object.keys(obj)
.filter((k) => obj[k] !== undefined)
.sort()

Null-valued fields. In-memory representations may keep null fields; after writing JSON (or similar), those fields are often omitted. Computing the digest from the pre-write shape and verifying from the post-read shape means the body has changed and verify fails. Correct write order: first converge the body to the on-disk shape, then compute the digest, then insert.

These issues rarely show up in write-only tests; full verify surfaces them. After confirming a false positive from serialization, digests can be recomputed in time order and written back. That path is not for masking real content tampering; export and retain first.

Self-confirmation and third-party approval are separate flows

Section titled “Self-confirmation and third-party approval are separate flows”

The audit chain answers whether records were rewritten. HITL answers whether a high-risk action passed a human gate, and who that gate was. They use different flows and events:

FlowConditionAudit eventEnters approval inbox
Self-confirmationActor allows this operation in-sessionpermission.*No
Third-party approvalAnother role approvesapproval.* (includes requester_id / decided_by)Yes

If both share one inbox table, HITL coverage stats count self-approval as human approval and the rate is wrong.

Rule: an actor cannot approve their own ticket in the approval center. Self-approval is not part of the approval flow. For accountability, search permission.* and approval.* separately.

In-session allow is existing permission capability; cross-person approval and recomputable audit are additional trust boundaries and must be measured and verified on their own.


Related: