Skip to content
VeritioDocs

Consent history

Kind
guide
For
developer · governance
Verified against
@veritio/core@0.4.7

A consent decision is only useful later if you can show what was granted, when it was withdrawn, and that neither statement was edited afterwards. Veritio has no consent ledger API, no consent table, and no consent state machine. Consent is recorded as ordinary audit events built by two templates, grouped by a shared consent id, carrying a lawful-basis label and a retention label, and made tamper-evident by the same hash chain every other Veritio event uses.

consentGrantedTemplate and consentRevokedTemplate are thin wrappers over one internal builder. They differ only in the action string. Both resolve the target to the same resource:

consentGrantedTemplate({ consentId: "cns_marketing_email_01" })
action consent.granted
target { type: "consent", id: "cns_marketing_email_01" }
consentRevokedTemplate({ consentId: "cns_marketing_email_01" })
action consent.revoked
target { type: "consent", id: "cns_marketing_email_01" } ← same resource

That shared target is the entire grouping mechanism. A consent-history screen selects on the tuple (scope.tenantId, target.type, target.id) and gets every lifecycle event for one consent, without a join table and without a hosted service. If a grant and its revocation use different consentId values, nothing in the protocol reconnects them; they become two unrelated one-event histories. Deriving the id from something stable in your own domain — the subject, the purpose, and the version of the notice they saw — is what keeps a history groupable across deploys.

The templates stamp these defaults:

purpose consent_management
lawfulBasis consent
retention consent_7y
target.type consent
metadata subjectId, purposeId (omitted entirely when not supplied)

Every one of those is overridable through the public AuditEventInput fields, so a template is a starting point and never a lock. Metadata is the exception worth knowing: the shared builder merges caller metadata with template metadata and gives the template final say over its reserved ids. A caller passing metadata.subjectId cannot shadow the subjectId derived from the template input. That is deliberate — grouping conventions must not be silently overwritten by application code.

src/examples/consent/grant-and-revoke.ts
import {
type AuditRecord,
MemoryAuditStore,
consentGrantedTemplate,
consentRevokedTemplate,
createAuditEvent,
verifyAuditRecords,
} from '@veritio/core'
export const consentScope = { tenantId: 'org_acme', environment: 'production' } as const
const consentId = 'cns_marketing_email_01'
const subjectId: string = 'sub_9f2c41'
/**
* Records a consent grant followed by its revocation on one tenant hash chain.
*
* The invariant this proves: both lifecycle events are built by the protocol
* templates rather than hand-written action strings, so they resolve to the
* SAME `consent` target resource and carry the same opaque `subjectId` in
* metadata. That shared resource identity is what lets a consent-history screen
* group a grant with its revocation, and the append order plus previous-hash
* linkage is what makes the resulting history tamper-evident evidence instead
* of prose. Ids and timestamps are pinned so the documented output stays
* reproducible while the store still owns sequence numbers and chain hashes.
*/
export async function recordConsentHistory(): Promise<AuditRecord[]> {
const store = new MemoryAuditStore()
const granted = createAuditEvent(
consentGrantedTemplate({
id: 'evt_consent_granted_01',
occurredAt: '2026-08-09T10:00:00.000Z',
actor: { type: 'user', id: 'usr_subject_9f2c41' },
consentId,
subjectId,
purposeId: 'purpose_marketing_email',
scope: consentScope,
}),
)
const revoked = createAuditEvent(
consentRevokedTemplate({
id: 'evt_consent_revoked_01',
occurredAt: '2026-08-14T16:30:00.000Z',
actor: { type: 'user', id: 'usr_subject_9f2c41' },
consentId,
subjectId,
purposeId: 'purpose_marketing_email',
scope: consentScope,
}),
)
await store.append(granted, { idempotencyKey: `consent:${consentId}:granted` })
await store.append(revoked, { idempotencyKey: `consent:${consentId}:revoked` })
return store.list(consentScope)
}
/**
* Projects the consent-facing protocol fields a history UI renders. Record
* hashes and `appendedAt` are deliberately excluded: the store stamps wall-clock
* append time into the record hash input, so those values are not reproducible
* in a byte-compared fixture.
*/
function consentView(record: AuditRecord) {
const { action, target, purpose, lawfulBasis, retention, metadata } = record.event
return { action, target, purpose, lawfulBasis, retention, metadata }
}
if (import.meta.main) {
const records = await recordConsentHistory()
const [granted, revoked] = records
if (!granted || !revoked) {
throw new Error('expected a consent grant and a consent revocation')
}
const output = {
granted: consentView(granted),
revoked: consentView(revoked),
sameConsentResource:
granted.event.target.type === revoked.event.target.type &&
granted.event.target.id === revoked.event.target.id,
sameSubject: granted.event.metadata.subjectId === revoked.event.metadata.subjectId,
sequences: records.map((record) => record.sequence),
previousHashLinked: revoked.previousHash === granted.hash,
verification: verifyAuditRecords(records),
}
console.log(JSON.stringify(output, null, 2))
}
Terminal window
bun src/examples/consent/grant-and-revoke.ts

The checked fixture prints:

verified output
{
"granted": {
"action": "consent.granted",
"target": {
"type": "consent",
"id": "cns_marketing_email_01"
},
"purpose": "consent_management",
"lawfulBasis": "consent",
"retention": "consent_7y",
"metadata": {
"purposeId": "purpose_marketing_email",
"subjectId": "sub_9f2c41"
}
},
"revoked": {
"action": "consent.revoked",
"target": {
"type": "consent",
"id": "cns_marketing_email_01"
},
"purpose": "consent_management",
"lawfulBasis": "consent",
"retention": "consent_7y",
"metadata": {
"purposeId": "purpose_marketing_email",
"subjectId": "sub_9f2c41"
}
},
"sameConsentResource": true,
"sameSubject": true,
"sequences": [
1,
2
],
"previousHashLinked": true,
"verification": {
"ok": true
}
}

Ids and timestamps are pinned so this output stays reproducible. Record hashes and appendedAt are excluded from the projection on purpose: the store stamps wall-clock append time into the record hash input, so those values cannot be byte-compared in a fixture. The store still owns them.

What each part of that output proves:

  • action distinguishes the two lifecycle points, and both use the protocol’s dotted lowercase form rather than a hand-written string.
  • target is byte-identical across both events. sameConsentResource: true is the machine-checked version of the grouping claim above.
  • purpose, lawfulBasis, and retention are template defaults that landed on the event and are therefore inside the record hash. A later silent edit of any of them produces a verification failure rather than a quiet rewrite of history.
  • metadata contains only two opaque ids. sameSubject: true confirms the revocation belongs to the same data subject as the grant.
  • sequences: [1, 2] and previousHashLinked: true show the store assigned gapless tenant-local sequence numbers and linked the second record to the first record’s hash.
  • verification: { "ok": true } is the verifier’s statement that the two supplied records are internally consistent under the declared algorithms.

Lawful basis rides on the event, not on metadata

Section titled “Lawful basis rides on the event, not on metadata”

lawfulBasis is a first-class top-level field on the audit event, constrained by an enum in spec/event.schema.json and by the LawfulBasis union in the SDK. The vocabulary is exactly seven values:

consent
contract
legal_obligation
vital_interests
public_task
legitimate_interests
not_applicable

Because the schema sets additionalProperties: false and the field is an enum, an unrecognized basis fails validation instead of being stored as free text. Because the field is part of the normalized event, it is inside the canonical bytes that the record hash covers.

The consent templates default to consent, which is correct for a record that exists precisely because a subject chose. Other templates in the same catalogue default differently — session and membership events default to contract, subject-request and retention events to legal_obligation — and any of them can be overridden per event. Veritio supplies the vocabulary and the determinism. Deciding which basis actually applies to your processing is your organization’s decision, taken with your own counsel, and the field records that decision rather than validating it.

retention is typed as a plain string in the event schema. There is no enum, no parser, and no duration arithmetic anywhere in the SDK. consent_7y is simply the default label the consent templates stamp — a Veritio naming default that points at a seven-year class by convention. It is not a legal determination, not a promise about any jurisdiction, and not an instruction that anything will be deleted.

Nothing in @veritio/core deletes records. Enforcement belongs to whatever system owns the authoritative store, and that system has to define the effective date, the time origin for eligibility, legal-hold behavior, derived-tier propagation, and what evidence a policy run produces. There is also a hard interaction with the chain: removing an interior record from a complete chain creates a sequence or previous-hash gap, and the verifier will correctly refuse to call the remainder intact. An intentional retention boundary therefore needs a deliberate verification design, not a DELETE. Retention labels covers that in full.

subjectId, purposeId, and consentId must be stable opaque identifiers. Never an email address, never a phone number, never a display name, never a freeform note.

This is not a stylistic preference, and the SDK cannot rescue you from ignoring it. Redaction in Veritio is deterministic and key-name based:

/(password|secret|token|api[_-]?key|authorization|email|phone|ssn)/i

subjectId does not match that pattern, and it must not — the whole point of the field is that it survives into the evidence so a history can be grouped. The consequence is direct: writing subjectId: "alice@example.com" puts that address through canonicalization and into the record hash verbatim. It is then permanent by construction, because the property that makes the record tamper-evident is the same property that makes it un-editable. A later erasure request cannot be satisfied by quietly rewriting the value.

consentId is worse still, because it lives in target.id rather than in metadata, and the redactor never touches the target at all. The same applies to the optional display strings on actor and target, and to actor.id. In the fixture the actor is usr_subject_9f2c41, not a person’s name.

A consent history has two distinct orderings and they answer different questions.

occurredAt when the subject acted → business truth
sequence when the record was appended → chain truth

The reconstruction that a history screen or a DSAR response should use is:

  1. Verify the records first, with verifyAuditRecords. An unverified list is not evidence.
  2. Filter to the tenant, target.type === "consent", and the target.id you are reporting on.
  3. Sort by occurredAt ascending, breaking ties on sequence.
  4. Fold left. The last event in occurrence order is the current state.

For the fixture that fold is unambiguous:

seq 1 occurredAt 2026-08-09T10:00:00.000Z consent.granted → active
seq 2 occurredAt 2026-08-14T16:30:00.000Z consent.revoked → revoked (current)

The revocation supersedes the grant because it occurred later, not because it was appended later. Here the two orderings agree. When they disagree — a revocation recorded at the boundary but appended after a backfilled grant — that divergence is itself a finding. Surface it. Do not silently reorder records to make the timeline look tidy, and never re-append a corrected event under the same idempotency key expecting the old one to disappear.

Superseding is a read-time projection, never a mutation. The grant record stays in the chain forever, and that is what lets you answer “what was true on 2026-08-11?” as well as “what is true now”.

The consent path inherits the SDK’s fail-closed behavior rather than adding its own. Each of these throws instead of producing a degraded record:

  • A missing or blank actor.id, actor.type, target.id, or target.type. An empty consentId fails here, before an event with an unusable grouping key can exist.
  • An action that does not match the dotted lowercase protocol pattern.
  • A missing scope.tenantId at append time. Consent evidence cannot be written into an unscoped chain.
  • An idempotency key replayed with different canonical event bytes. The store raises an idempotency conflict rather than choosing a winner. Replaying the exact same bytes returns the original record, which is what makes a retried consent write safe.
  • An expectedPreviousHash that no longer matches the tenant chain tip, which is how a concurrent writer is caught instead of silently interleaved.

The fixture uses explicit keys — consent:<consentId>:granted and consent:<consentId>:revoked — so a retried request cannot append a duplicate grant.

A verified consent chain is a narrow claim, and overreading it is the most common mistake.

It proves the supplied records are internally consistent under the declared hashing and canonicalization, that they belong to one tenant chain in a gapless order, and that no byte of an included record changed after it was appended.

It does not prove that the notice a subject saw said what you believe it said, unless you also recorded a digest of that notice version. It does not prove the subject understood it. It does not prove that every relevant consent event was recorded — an event that was never written leaves no gap, so completeness is a property of your instrumentation, not of the chain. It does not prove that downstream processors, caches, exports, or third parties actually stopped processing after the revocation; that requires its own evidence. And a locally consistent chain can still be replaced wholesale by an attacker who controls the entire store, which is why independently held exports and evidence commits matter.

Veritio supports consent evidence. It does not guarantee legal compliance, and nothing on this page is legal advice. The tooling gives you deterministic, verifiable records; whether those records satisfy a particular obligation is a question for your own counsel.

Continue with Hash chain for the integrity mechanism these records rely on, Retention labels for why a label never deletes anything, and DSAR fulfillment for turning a subject’s consent history into a response.