Skip to content
VeritioDocs

DSAR fulfillment

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

A data-subject access request asks you to hand one person the records that concern them. A tenant hash chain is not organized that way: it interleaves every subject, so pulling one subject’s records out leaves interior gaps and the extract can no longer satisfy the strict chain rule. Veritio’s answer is not to relax the rule but to make the extract declare what it is, so a recipient reads a valid verdict for exactly what it proves.

This page is evidence tooling. It does not decide whether a request is valid, who the subject is, or what you are legally obliged to disclose. Nothing here is legal advice.

A fulfillment produces evidence at three moments, and all three are ordinary events on the same tenant chain.

1. intake data.subject.request.created ← the request arrived
2. extraction (host selects the subject's records)
3. delivery export.bundle.created ← this bundle was handed over

Steps 1 and 3 are templated by @veritio/core. Step 2 is host logic — only the application knows which records concern a subject. What the SDK owns is the honesty of the container that carries them.

dataSubjectRequestCreatedTemplate fixes the fields that make an intake event comparable across tenants and across time:

Field Value
action data.subject.request.created
target { type: 'subject_request', id: <subjectRequestId> }
purpose data_subject_workflow
lawfulBasis legal_obligation
retention subject_request_3y
metadata { requestType, subjectId }, with absent members omitted

The last three are defaults, not locks: a caller may pass purpose, lawfulBasis, or retention and override them. The action and target shape are the template’s own and are not overridable through the input type. retention: 'subject_request_3y' is a named policy label carried on the record, not an instruction to any store to delete anything — see Retention.

Note what does not go in. metadata carries subjectId, a stable identifier, and requestType — not the requester’s email, name, address, or the free text of their message. Metadata is redacted deterministically by createAuditEvent before canonicalization and hashing, so anything sensitive you pass is redacted in the bytes that get hashed and can never be recovered from the record. Keep personal data out of the event and reference it by id.

Once the request event is appended, the tenant chain holds it interleaved with every other subject’s records. In the worked example below, four records are appended in one tenant and only two of them concern the requesting subject:

tenant chain seq 1 seq 2 seq 3 seq 4
dsub_1001 dsub_2002 dsub_1001 dsub_2002
subject extract seq 1 ──────────── seq 3
interior gap: seq 2 was deliberately excluded

Fed to the strict full-chain verifier, that extract fails: sequence 3 does not follow sequence 1, and its previousHash points at a record the bundle does not contain. That failure is correct. The extract genuinely is not a complete chain.

chainScope resolves this without weakening anything. It is a claim the bundle makes about itself, written into manifest.chainScope, hashed with the rest of the manifest and therefore covered by any signature:

  • full (the field absent) — every per-tenant chain starts at sequence 1 with a null previousHash and is gapless. Only this scope proves nothing was removed.
  • windowed — a contiguous time window. The first record per tenant may enter mid-chain; every record after it must link strictly. Interior removal is still detectable.
  • filtered — a content-filtered subset. Interior gaps are inherent, so the verifier proves per-record integrity and strictly increasing per-tenant sequences, and still enforces strict linkage wherever two records happen to be sequence-adjacent.

A DSAR extract is filtered.

buildExportBundle refuses three inconsistent inputs before it serializes anything:

filters set, chainScope ≠ 'filtered'
→ "export bundle: filters require chainScope 'filtered'"
chainScope 'filtered', filters absent
→ "export bundle: chainScope 'filtered' requires a filters declaration"
chainScope ≠ 'full', commits present
→ "export bundle: commits are not supported in a scoped bundle"

The first stops the worst failure mode: a bundle that quietly removed content while still advertising the strict claim. The second is the mirror image — the filtered claim is only meaningful when the manifest says what was filtered on, so the declaration is mandatory whenever the scope is. The third exists because commit chains are defined over the full ledger; a subset of them is not a defined object in v1, and verifyCommitChain always runs under the strict rule regardless of scope.

verifyExportBundle re-checks all three from the bytes alone, because a bundle can arrive from anywhere and may never have been through this builder. An unknown chainScope value, or filters present under a non-filtered scope, fails the structure gate rather than silently falling back to full. Commit records inside a scoped bundle fail the chains gate with commits are not supported in a scoped bundle.

The example builds the four-record tenant chain above, extracts the two records for dsub_1001, builds the bundle twice — once correctly as filtered, once claiming the same subset is a full chain — and verifies both.

src/examples/dsar/subject-bundle.ts
import {
type ExportBundleInput,
MemoryAuditStore,
buildExportBundle,
consentGrantedTemplate,
consentRevokedTemplate,
createAuditEvent,
dataSubjectRequestCreatedTemplate,
parseExportBundle,
serializeExportBundle,
verifyExportBundle,
} from '@veritio/core'
const scope = { tenantId: 'org_acme', environment: 'production' } as const
const subjectId = 'dsub_1001'
/**
* Proves what a data-subject export can and cannot claim in the published SDK.
*
* `dataSubjectRequestCreatedTemplate` gives the DSAR intake event a fixed
* action, target type, purpose, lawful basis, and retention class, so the
* request itself is evidence on the same tenant hash chain as the consent
* records it is about. Selecting only one subject's records out of that chain
* leaves interior gaps, so the bundle MUST declare `chainScope: 'filtered'`:
* the second build here claims the same subset as a full chain and the verifier
* fails it closed, which is the invariant that stops a subject-scoped extract
* from being passed off as a complete chain.
*
* Honest boundary: the SDK templates the DSAR *event* and the bundle format.
* Selecting which records belong to a subject is host logic — vevb-1
* `manifest.filters` can only declare `workspaceId` / `actionPrefixes`, so a
* subject predicate is not expressible in the manifest and is declared here as
* an empty (but present) filters object. Fulfilment, identity proof, deadlines,
* and delivery are the host's to implement.
*
* Record hashes, `rootHash`, and `appendedAt` are omitted from the output on
* purpose: `MemoryAuditStore` stamps `appendedAt` from the wall clock, so those
* values are not reproducible byte-for-byte.
*/
export async function buildSubjectBundle() {
const store = new MemoryAuditStore()
const consentGranted = createAuditEvent(
consentGrantedTemplate({
id: 'evt_consent_granted_01',
occurredAt: '2026-08-09T09:00:00.000Z',
scope,
actor: { type: 'user', id: 'usr_1001' },
consentId: 'con_1001',
subjectId,
purposeId: 'product_analytics',
}),
)
const otherSubjectConsent = createAuditEvent(
consentGrantedTemplate({
id: 'evt_consent_granted_02',
occurredAt: '2026-08-09T09:05:00.000Z',
scope,
actor: { type: 'user', id: 'usr_2002' },
consentId: 'con_2002',
subjectId: 'dsub_2002',
purposeId: 'product_analytics',
}),
)
const subjectRequest = createAuditEvent(
dataSubjectRequestCreatedTemplate({
id: 'evt_subject_request_01',
occurredAt: '2026-08-09T09:10:00.000Z',
scope,
actor: { type: 'user', id: 'usr_1001' },
subjectRequestId: 'dsr_0007',
requestType: 'access',
subjectId,
}),
)
const otherSubjectRevocation = createAuditEvent(
consentRevokedTemplate({
id: 'evt_consent_revoked_01',
occurredAt: '2026-08-09T09:15:00.000Z',
scope,
actor: { type: 'user', id: 'usr_2002' },
consentId: 'con_2002',
subjectId: 'dsub_2002',
purposeId: 'product_analytics',
}),
)
await store.append(consentGranted, { idempotencyKey: 'consent:con_1001:granted' })
await store.append(otherSubjectConsent, { idempotencyKey: 'consent:con_2002:granted' })
await store.append(subjectRequest, { idempotencyKey: 'subject-request:dsr_0007' })
await store.append(otherSubjectRevocation, { idempotencyKey: 'consent:con_2002:revoked' })
const chain = await store.list(scope)
const subjectRecords = chain.filter((record) => record.event.metadata.subjectId === subjectId)
const bundleInput: ExportBundleInput = {
scope: { tenantId: scope.tenantId, environment: scope.environment },
range: { from: '2026-08-09T09:00:00.000Z', to: '2026-08-09T09:10:00.000Z' },
producer: {
authority: 'self-hosted-example',
kind: 'principal',
type: 'service',
id: 'dsar_worker',
},
createdAt: '2026-08-09T09:20:00.000Z',
events: subjectRecords,
edges: [],
commits: [],
}
const bundle = await buildExportBundle({
...bundleInput,
chainScope: 'filtered',
filters: {},
})
const parsed = parseExportBundle(serializeExportBundle(bundle))
const verification = await verifyExportBundle(parsed)
const overclaimed = await buildExportBundle(bundleInput)
const overclaimedReport = await verifyExportBundle(overclaimed)
return {
requestEvent: subjectRequest,
chain: {
sequences: chain.map((record) => record.sequence),
subjectSequences: subjectRecords.map((record) => record.sequence),
subjectActions: subjectRecords.map((record) => record.event.action),
},
manifest: {
bundleVersion: parsed.manifest.bundleVersion,
createdAt: parsed.manifest.createdAt,
scope: parsed.manifest.scope,
range: parsed.manifest.range,
chainScope: parsed.manifest.chainScope,
filters: parsed.manifest.filters,
files: parsed.manifest.files.map((file) => ({ path: file.path, records: file.records })),
},
containerRoundTrip: parsed.manifest.rootHash === bundle.manifest.rootHash,
verification,
sameSubsetClaimedAsFullChain: {
valid: overclaimedReport.valid,
chains: overclaimedReport.checks.chains,
chainScope: overclaimedReport.chainScope,
issues: overclaimedReport.issues,
},
}
}
if (import.meta.main) {
console.log(JSON.stringify(await buildSubjectBundle(), null, 2))
}

Its checked output:

verified output
{
"requestEvent": {
"id": "evt_subject_request_01",
"schemaVersion": "2026-06-10",
"occurredAt": "2026-08-09T09:10:00.000Z",
"actor": {
"type": "user",
"id": "usr_1001"
},
"action": "data.subject.request.created",
"target": {
"type": "subject_request",
"id": "dsr_0007"
},
"metadata": {
"requestType": "access",
"subjectId": "dsub_1001"
},
"scope": {
"tenantId": "org_acme",
"environment": "production"
},
"purpose": "data_subject_workflow",
"lawfulBasis": "legal_obligation",
"retention": "subject_request_3y"
},
"chain": {
"sequences": [
1,
2,
3,
4
],
"subjectSequences": [
1,
3
],
"subjectActions": [
"consent.granted",
"data.subject.request.created"
]
},
"manifest": {
"bundleVersion": "vevb-1",
"createdAt": "2026-08-09T09:20:00.000Z",
"scope": {
"environment": "production",
"tenantId": "org_acme"
},
"range": {
"from": "2026-08-09T09:00:00.000Z",
"to": "2026-08-09T09:10:00.000Z"
},
"chainScope": "filtered",
"filters": {},
"files": [
{
"path": "records/audit-events.jsonl",
"records": 2
},
{
"path": "records/evidence-edges.jsonl",
"records": 0
},
{
"path": "records/commits.jsonl",
"records": 0
},
{
"path": "verification.json",
"records": 0
}
]
},
"containerRoundTrip": true,
"verification": {
"valid": true,
"checks": {
"structure": true,
"integrity": true,
"chains": true,
"signature": "absent"
},
"chainScope": "filtered",
"issues": []
},
"sameSubsetClaimedAsFullChain": {
"valid": false,
"chains": false,
"chainScope": "full",
"issues": [
"record chain verification failed"
]
}
}

Reading it field by field:

  • requestEvent shows the template’s fixed action, target, purpose, lawfulBasis, and retention on a real event, with metadata reduced to two identifiers.
  • chain.sequences [1,2,3,4] versus chain.subjectSequences [1,3] is the gap, stated numerically. The bundle carries two records whose sequences are not adjacent.
  • manifest.chainScope: "filtered" with manifest.filters: {} is the declaration. The filters object is present and empty on purpose: vevb-1 filters can express workspaceId and actionPrefixes only, and “records concerning one subject” is not either of those. The example declares presence honestly rather than inventing a field the protocol does not have. A production exporter that filters by action prefix should say so there.
  • manifest.files always lists all four required paths — the two empty record files included, with records: 0 — because vevb-1 requires them present whether or not they hold anything.
  • containerRoundTrip: true proves serialize-then-parse preserved the manifest’s binding digest, so the bundle survives being written to disk or a response body.
  • verification.checks shows the four independent gates. signature: "absent" is a satisfied state, not a failure: an unsigned bundle only fails when the caller passed requireSignature. buildExportBundle never signs — signing is a separate signExportBundle step.
  • verification.chainScope: "filtered" is the field a consumer must read before trusting valid: true. It travels with the verdict precisely so valid cannot be quoted without its claim.
  • sameSubsetClaimedAsFullChain is the same two records built with no scope declaration. valid: false, chains: false, chainScope: "full", issues: ["record chain verification failed"]. The overclaim is caught by the verifier, not by convention.

Filtered is a narrower claim, not an unchecked one. Every record in the bundle is held to the full per-record envelope rules: tenant scope present, hashAlgorithm sha256, canonicalization veritio-json-v1, and an envelope hash that recomputes byte-for-byte. Beyond that, per tenant:

first record seen seq 1 → previousHash MUST be null
seq > 1 → previousHash MUST be a string
sequence-adjacent previousHash MUST equal the prior record's hash
(strict linkage, same as full)
across a gap sequence MUST strictly increase
previousHash MUST be a string

So a filtered bundle still detects an edited record, a reordered pair, a duplicated sequence, and a broken link between two records that were adjacent. Malformed records are caught too: the scoped walkers wrap hash recomputation and return malformed_record rather than throwing.

The bundle leaving your system is itself a governed action, so record it. exportBundleCreatedTemplate fixes action: 'export.bundle.created', target: { type: 'export_bundle', id: <exportBundleId> }, purpose: 'data_subject_workflow', lawfulBasis: 'legal_obligation', and retention: 'export_1y', with metadata: { format }.

Its metadata surface is deliberately one field. Bundle contents belong in the bundle, referenced from the event by stable id — and, usefully, by the manifest’s rootHash, which binds the file set into a single digest. Passing that hash as metadata lets you later prove which bundle was delivered without copying subject data into the audit record.

Because the delivery event is appended to the same tenant chain, the fulfillment is self-documenting: the intake event and the delivery event sit in the chain with a verifiable order between them.

A valid: true report on a filtered bundle proves that each included record is authentic, internally consistent, and in ascending order for its tenant, and that the manifest binds exactly the files present. It does not prove completeness. It cannot: the verifier has no way to know what your host-side selection predicate excluded, and filters describes the filter, not its correctness. If your query missed a record, verification stays green.

It also does not prove the extract matches the request. Whether dsub_1001 is the person who asked, whether their identity was checked, whether the response met a statutory deadline, and whether the disclosure was complete under any particular regime are host and organizational concerns. Veritio records that a request was created and that a bundle was produced; it does not adjudicate either.

Finally, an unsigned bundle proves nothing about who produced it. manifest.producer is a self-declared string. Pass requireSignature and a public key to verifyExportBundle when producer authenticity matters.

dataSubjectRequestCreatedTemplate and exportBundleCreatedTemplate exist in all three SDKs — data_subject_request_created_template / export_bundle_created_template in Python, DataSubjectRequestCreatedTemplate / ExportBundleCreatedTemplate in Go — with identical actions, targets, purposes, lawful bases, and retention labels.

buildExportBundle, verifyExportBundle, signExportBundle, and the chain-scope verifiers are TypeScript only. There is no export-bundle module in the Python or Go SDKs at this revision. A DSAR flow in those languages can record both events natively, but assembling or verifying a vevb-1 bundle needs the TypeScript SDK or an independent implementation built from spec/export-bundle.md, which is normative, pinned by conformance fixtures, and linked from this page’s sourceRefs.

Continue with the export format reference for the full manifest and container contract, Consent history for the records a subject request usually reaches for, and the verifier reference to interpret each gate and issue string. The underlying invariants are in Hash chain.