The access log
The access log is your company's tamper-evident register. Every check decision and every consent lifecycle event is recorded there, hash-chained, and folded periodically into an epoch whose root is anchored on-chain. It is the piece that answers "can you demonstrate what you did, and prove the register was not touched up afterwards". It equips the accountability obligation (P-39.1 art. 3.1; for a public body, A-2.1 art. 52.2).
A verifiable register, not proof of compliance
The log demonstrates that the recorded sequence of events is intact and complete up to the last anchored epoch. It does not certify that your practices are compliant, and it says nothing about what your other systems did with the information. Agreely documents and demonstrates; it does not certify.
What is recorded
One entry is written for each of these events, on your own subjects:
| Event | What it records |
|---|---|
check |
A check decision, allow or deny, with its status. |
grant |
A consent granted. |
revoke |
A consent withdrawn. |
erase |
An erasure (crypto-shred). |
refuse |
An offer refused by the person. |
view |
A read of a stored personal-information value (for example decrypting a customer's email for one of the permitted surfaces). It carries the reference, never the value. |
The subject is identified by your own customer reference, never by a citizen DID: the log creates no linkable tie to the citizen layer. See unlinkability.
The write is off the hot path. The synchronous check drops the event in an inbox and answers; a single-writer worker per company (a Postgres advisory lock) then drains the inbox into the chain. A logging failure never slows or breaks a check.
The hash chain
Every row commits to the one before it:
row_hash = keccak256( prev_hash || canonicalJson(payload) )
prev_hash of the first record = 32 zero bytes
payload = {
"chain_seq": 1024,
"event_type": "check",
"decision": "deny",
"status": "revoked",
"occurred_at": "2026-06-26T15:04:05Z",
"consentRef": "0x…" | null,
"commitment": "0x…"
}
canonicalJson is RFC 8785 canonical serialization: keys sorted, no whitespace,
/ unescaped, UTF-8 unescaped. Note that chain_seq travels here as a JSON
number; the "no numbers" rule described in
canonicalization and hashing
applies to the consent claim, not to this payload.
chain_seq is contiguous with no gap: a deletion in the middle of the chain
leaves a gap, and a deletion at the tip breaks the seam of the anchored epoch that
covered it.
The chain hashes the commitment, never the readable text
The commitment is the same keccak256( JCS(claim) || salt ) as the rest of the protocol. The category, the purpose and the customer reference are bound into history only through it. That is what lets a crypto-shred null the readable columns and the salt while leaving the row_hash, the epoch root and the on-chain anchor fully verifiable. An erasure never punches a hole in the register.
Epochs and anchoring
Periodically (on the first of two thresholds, an entry count or an elapsed delay), the not-yet-anchored rows are folded into an epoch:
- a Merkle tree is built over the range's
row_hashvalues, inchain_seqorder; - the root and the
head_hash(therow_hashatseq_to) are stored; - the root, one opaque
bytes32, is queued for on-chain anchoring (anchorAuditRoot, eventAuditRootAnchored).
The on-chain payload carries no cleartext company identifier, no consentRef,
no personal information, no DID. See the
on-chain registry.
Each epoch root is additionally signed at seal time with your company's registered DID key, and that key can be authorized by your own root passkey committed in your DNS. It is an additive layer, and it never gates integrity: see epoch signing and delegation.
The Merkle convention is part of the recipe
The tree is OpenZeppelin's SimpleMerkleTree, and its exact shape is published
inside the export itself, because an external auditor once implemented the
obvious reading (pair adjacent, keccak(a||b), duplicate the odd tail) and got a
different root:
- Leaf: the
row_hashas-is, never re-hashed. - Pair:
keccak256(a || b)whereaandbare the two 32-byte children, ordered ascending as byte strings. - Layout: the OpenZeppelin
2N-1node array, theNleaves written at the end of the array in reverse order (leafiat index2N-2-i), nodei= the pairing of nodes2i+1and2i+2, root at index0. An odd node is promoted, never duplicated. N = 1is not special-cased: the root is the single leaf.
This layout is not equivalent to level-by-level pairing with promotion: past four leaves, the two produce different roots.
Verification, and its honest boundary
The log screen shows a verification state. Two paths exist.
The fast path, run on every page render, costs O(open tail + number of epochs), not O(all rows). It proves the contiguity of chain_seq, the
integrity of the open tail and its link onto the last anchored head_hash,
the integrity of each anchored epoch seam, and a truncation guard.
The deep path, on demand, replays the whole chain: every row_hash is
recomputed and compared, and for every non-erased row the commitment is
recomputed from the claim and the decrypted salt. It is that content check
that catches a rewrite of the readable columns, which would otherwise leave the
chain replaying cleanly.
The open tail is not chain-proven
Rows past the last anchored seq_to are only as trustworthy as the live database, until their root lands on-chain. This is declared as such (openTail) on the screen and in the export, and it is never presented as chain-proven.
The export
The register exports as JSON or CSV, in the agreely.access-log.v1
format. The export is self-sufficient: beyond the entries, it carries
everything needed to re-verify it offline, without Agreely and without reading our
code.
- the
row_hashrecipe and the genesis; - the full Merkle convention, spelled out;
- the epochs with their
seq_from,seq_to, root,head_hashand anchoring transaction; - the
AgreelyRegistrycontract address, thechainIdand the chain name, because a transaction carrying a root proves only that someone wrote thatbytes32somewhere; without the contract and the chain, an auditor cannot confirm the registry is ours. The chain name is also what would disclose an anchor on a test network as such; - the epoch signature, its public key and its algorithm, when present;
- the verification state (
chainOk,contentOk,epochsOk,anchoredOk,openTail) and the caveats.
An export filtered to one customer is a projection: each entry still
carries its own prev_hash and row_hash, but a contiguous chain replay needs the
unfiltered company export. The scope is declared in the artifact so a detached
slice is never mistaken for the whole register.
Next
- Auditor access: open this register read-only to an external reviewer.
- Epoch signing and delegation: the key that signs each root, and the DNS-bound delegation.
- On-chain registry: where the root lands, and what the chain proves.
- Citizen portal: the view a person has of their own accesses.