FR EN

The access log

The access log (menu Compliance > Access log) is your company's register built to make a touch-up detectable. Every check decision and every consent lifecycle event is recorded there, hash-chained, and folded periodically into an epoch (a "checkpoint" on screen) whose root is anchored on-chain. It is the piece that answers "can you demonstrate what you did, and let someone verify the register was not touched up afterwards". It equips the accountability obligation (P-39.1 s. 3.1; for a public body, A-2.1 s. 52.2).

A verifiable register, not proof of compliance

For each anchored range the full export still lists with its original transaction, recomputing the root and comparing it with the one the transaction emitted shows whether an entry in that range was modified or removed. A range removed together with its checkpoint leaves nothing to compare, and a change to the person, category or purpose of an entry cannot be verified from the export alone. The log 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.
disposition An end-of-retention disposition declared by your own system for a customer (destruction or anonymization), through the API.
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:

  1. a Merkle tree is built over the range's row_hash values, in chain_seq order;
  2. the root and the head_hash (the row_hash at seq_to) are stored;
  3. the root, one opaque bytes32, is queued for on-chain anchoring (anchorAuditRoot, event AuditRootAnchored).

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: it depends on the epoch's Merkle version (merkleVersion). In version 1, the row_hash as-is, never re-hashed. In version 2, keccak256(0x00 || 0x01 || row_hash), a 34-byte preimage where 0x01 is the access-log lane byte.
  • Pair: keccak256(a || b) where a and b are the two 32-byte children, ordered ascending as byte strings.
  • Layout: the OpenZeppelin 2N-1 node array, the N leaves written at the end of the array in reverse order (leaf i at index 2N-2-i), node i = the pairing of nodes 2i+1 and 2i+2, root at index 0. An odd node is promoted, never duplicated.
  • N = 1 is 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: log consistent with stored fingerprints, awaiting first anchor or integrity anomaly detected. 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.

Neither path reads the chain: both compare against the entry and checkpoint fingerprints Agreely keeps. It is defence in depth, not independent proof: anyone holding write access to Agreely's database (and, for a rewrite, its encryption keys), Agreely included, could delete or rewrite entries along with their fingerprints. The comparison with the chain happens outside Agreely, from the full export.

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.v2 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_hash recipe and the genesis;
  • the full Merkle convention, spelled out;
  • the epochs with their seq_from, seq_to, root, head_hash, Merkle version and anchoring transaction. The transaction is the one the indexer recorded, not a chain read: the auditor's own comparison is what holds, and an epoch with no transaction is only as trustworthy as the database;
  • the AgreelyRegistry contract address, the chainId and the chain name, because a transaction carrying a root proves only that someone wrote that bytes32 somewhere; 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