Full-fidelity tenant export and the deletion certificate
Two related, but separate, guarantees:
- A tenant can export every model's tenant slice, in a documented JSON format, on demand.
- Offboarding produces a deletion certificate — a document that states exactly what was requested, confirmed and executed, and is explicit about what it does not claim.
Removing the lock-in objection only works if the completeness claim is provable, not asserted. Both halves below exist to make "full-fidelity" checkable rather than trust-me.
The export
POST /data-export (Owner role) generates a JSON archive of the calling
tenant's data and returns a 24-hour download URL. The archive includes every
tenant-scoped table by default: a new table is part of the export unless it is
deliberately withheld, and every withheld table is listed in the archive with
its reason.
What is withheld. Authentication, session and credential material is not customer-authored content and is never exported: refresh tokens, directory (LDAP) and SAML configuration secrets, inbound-webhook secrets, API keys and idempotency records. A table that belongs to the platform rather than to any single tenant (for example the shared auditor directory profiles) is also excluded and listed. Tables that belong to the tenant but are reached through a parent record (for example invoices, which belong to a contract) are included.
GET /data-export/plan returns the coverage manifest (included, parent-scoped,
excluded and platform-excluded tables, with reasons and no row data) so the
scope is inspectable before you run a full export.
Archive shape (exportVersion: "2.4")
{
"exportedAt": "2026-08-22T00:00:00.000Z",
"exportVersion": "2.4",
"format": {
"documentation": "docs/developers/data-export-format.md",
"modelsIncluded": 182,
"modelsExcluded": 8,
},
"organization": {
"id": "...",
"name": "...",
"slug": "...",
"industry": "...",
"tier": "...",
"status": "...",
"createdAt": "...",
},
"members": [
{
"role": "OWNER",
"joinedAt": "...",
"user": { "id": "...", "email": "...", "name": "...", "createdAt": "..." },
},
],
"models": {
"risks": [/* raw rows */],
"invoices": [/* raw rows, resolved via the parent contract */],
"...": "one array per included table, keyed by its database table name",
},
"excludedModels": [{ "table": "refresh_tokens", "model": "RefreshToken", "reason": "..." }],
"proofLog": { /* log proofs for exported receipts; see "Transparency-log proofs" below */ },
"configBundle": {
/* redacted configuration record; see "The configuration bundle" below */
},
"seal": { /* G17 — always the LAST member; see "The export seal" below */ },
}
The modelsIncluded and modelsExcluded counts grow as new tenant-scoped
tables ship. Do not treat a specific count as a permanent fact: read it from
your own export's format field, or from GET /data-export/plan.
organization and members are curated views. members carries a
deliberately narrow user profile (id, email, name, created date) because one
person can belong to many tenants, and a raw user row would fall outside "this
tenant's data". The raw membership rows (role, timestamps, business unit and
custom role) are still exported under models.org_memberships; the two are
views of the same underlying rows.
Binary columns (exportVersion: "2.2")
A few included tables carry binary data rather than text: a passkey's public key, a tenant's published proof-receipt verification key, and a proof receipt's signed bytes, signature and evidence manifest. Both export formats are text (JSON and CSV), so a binary value is rendered as an explicit marker object rather than the raw bytes:
{ "$bytes": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..." } // base64 of the exact original bytes
In the JSON archive this is an ordinary nested object at that column's key. In the CSV bundle it is the same object, JSON-encoded into that column's one cell (see "Cell format" below). Which fields get this treatment is decided by the column's declared type, never guessed from a value's shape.
Archives taken before exportVersion: "2.2" that touched a binary column carry
a numeric-key object ({"0":48,"1":130,...}) instead of the marker. Re-import
accepts both shapes, validating that the keys are a contiguous run of 0 to 255
integers before treating the value as bytes. A value that is neither shape is
refused with the registered error TENANT_IMPORT_INVALID_BYTES, naming the
table, field and row; it is never a best-effort guess. The private signing-key
table is never exported.
The export seal (seal, exportVersion: "2.4")
The JSON archive of every on-demand export ends with a seal member, so the hosted service can restore an
export that it produced for your workspace, and nothing else. The CSV and files ZIPs are not sealed and cannot
be restored.
"seal": {
"version": "cmdash-export-seal/1",
"keyId": "<which deployment key sealed it>",
"sealId": "<uuid>",
"nonce": "<base64url of 32 random bytes>",
"tenantId": "<source organization id>",
"exportJobId": "<export job id>",
"exportVersion": "2.4",
"exportedAt": "<ISO-8601>",
"digest": "<sha256 hex of the canonical archive without seal>",
"rowCounts": { "<table>": 0 },
"mac": "<base64url keyed MAC over the seal fields>"
}
- Digest. SHA-256 over the canonical form of the parsed archive without
seal: object members sorted by name, no whitespace, values written asJSON.stringifywrites them. - Verification. Only the deployment that sealed an export can verify it, and nobody can forge a seal without that deployment's secret. A seal is single-use, expires, and is bound to the workspace that exported it.
- Restore. Restoring needs an owner, a dry run, a fresh sign-in and, for a new workspace, a one-time code sent to the owner's address. Exports taken before this version carry no seal and cannot be restored: export again. The user guide describes the steps.
- If sealing is unavailable the archive is still returned, unsealed, and cannot be restored on the hosted service.
- The importer accepts archives of versions 2.0 to 2.4 and ignores
seal.
Transparency-log proofs (proofLog, exportVersion: "2.3")
models.proof_receipts carries each receipt's exact signed bytes and its
logIndex. The platform's log tables are not any tenant's rows and stay in
excludedModels with reasons. So that a receipt stays checkable after the
archive leaves, the archive adds a top-level proofLog.entries array, one
element per exported receipt:
status: "PROVEN"withsection(the log section:index,treeSize,rootHash,proof,checkpoint). The path is verified against the signed root before it is returned.status: "NOT_LOGGED": no signed checkpoint covers the receipt yet.status: "UNAVAILABLE"with areason: the log could not produce a verified proof. It is never rendered as proven.
The section is taken against the log's latest checkpoint at export time. The
key is additive: the importer reads only models, accepts 2.0 to 2.3, and
ignores proofLog. The tenant's private signing key is never exported. The
public statement of all this is the Exit Guarantee page. Re-import is available
on self-hosted and licensed deployments only.
Downloads: always a fresh link
The download URL returned when the export is created is signed once and expires. Use the download routes, which sign a fresh link on every call:
GET /data-export/:id/jsonGET /data-export/:id/csv
Both require the Owner or Admin role, and both return 404 rather than a link
to an object that does not exist.
The configuration bundle (configBundle)
Six tables are withheld from models as credential material; four of them also
hold the tenant's configuration, so a customer reading their own archive would
otherwise find their SSO setup simply absent. configBundle is the redacted
record of it: configBundle in the JSON archive and config-bundle.json in the
CSV zip.
Sections: sso.saml, sso.scimGroups, directory.ldap,
integrations.connections, integrations.inboundWebhookSecrets,
notifications.channels, notifications.alertRules, outboundWebhooks,
apiKeys, frameworkScope, customFields.
No credential column is ever read for the bundle, so a secret is never in it.
Free-form configuration values whose key names a secret are replaced with
[redacted], with the keys kept. The bundle's own redaction block and the CSV
README restate the list.
Two caveats worth repeating wherever this is described:
- Notification channels and integration connections are ordinary tenant
tables, so their
configJSON is also inmodelsunredacted.configBundleis the readable view, not a filter over the rest of the archive. Treat every archive as sensitive. - It is a record, not a restore artifact. Import reads
modelsand nothing else, soconfigBundlehas no effect on import. The data-import guide describes what a configuration restore involves.
The bundle's version is tracked separately from exportVersion, so a
bundle-only change does not invalidate an archive for the importer's version
check.
Row cap
Each table is capped at 20,000 rows per export, which keeps one export job's memory and JSON size bounded while being generous for essentially every tenant.
The CSV export
A second output mode, produced in the same job as the JSON archive, from the
same collected snapshot. Both formats are serializations of one answer to "what
is this tenant's data", so they can never disagree about which tables or rows
exist at a given exportVersion.
Shape: a ZIP containing:
<table>.csv— one per table in the archive'smodels(exactly the setGET /data-export/planlists), even when that table has zero rows for this tenant, so a table is never silently absent.organization.csv/members.csv— CSV renderings of the same two curated views described above.README.txt— format notes, plus the same exclusion list (table and reason) as the JSON archive'sexcludedModels, so a CSV-only consumer sees the exclusions stated as plainly as the inclusions.config-bundle.json— the configuration bundle described above.
Cell format: RFC 4180 (comma-separated, quoted where needed, doubled
internal quotes, CRLF line endings). A column whose value is JSON, an array or a
nested object is rendered as that value's JSON text in one cell, not expanded
into further columns, which is lossless and matches what the JSON archive calls
that column. A binary column is the same rule applied to the
{"$bytes": "<base64>"} marker. Dates are ISO 8601 UTC strings; null is an
empty cell (indistinguishable in the CSV from a stored empty string, as in the
JSON archive).
Versioning: the CSV bundle does not carry its own version number; its shape
is tied to the JSON archive's exportVersion.
Route: GET /data-export/:id/csv returns { downloadUrl } for a completed
job's CSV file. An export created before the CSV output existed returns a clear
404 ("request a new export") instead of a link to a file that was never
written.
See also: OSCAL export
OSCAL export (GET /oscal/:frameworkSlug/{system-security-plan, component-definition,assessment-results}) is a different, narrower
artifact from everything above: one federal-framework submission document (SSP,
component definition or assessment results) for a framework a tenant has
assessed itself against, not a full-tenant portability format. It does not
attempt to force CMDash's generic business tables (risks, vendors, policies,
incidents) into OSCAL's schema, and every field CMDash cannot derive is stamped
[PLACEHOLDER — REQUIRES HUMAN COMPLETION] rather than invented. The framework
coverage page lists which frameworks support it.
Neither format replaces the other; "the export" above means the full-tenant JSON/CSV archive unless stated otherwise. OSCAL export is reached through the API, as the data portability guarantee page states.
The import
Re-import of an archive is available on self-hosted and licensed deployments. It
imports into the caller's own tenant (POST /data-import, Owner role), with a
dry-run POST /data-import/preflight that reports every blocker at once and
writes nothing. Preflight checks conflicts, seat quota and format version. It
cannot predict failures that only appear while inserting (a dangling reference,
a malformed binary value); those surface during the apply.
Accepted format versions. Versions 2.0, 2.1 and 2.2 are accepted (and the
2.3 proofLog key is ignored), not only the current one, so an older archive you
already hold remains restorable. They carry an identical models section, which
is all the importer reads.
The import policy:
- Conflict policy: a non-empty target is refused (
409). Merging two independent id spaces is a much larger design; refusing is always safe to retry against a clean tenant. - Quota: the plan's seat limit is checked against the archive's membership row count before anything is written.
- References: every tenant-scope identifier is remapped to the target tenant. Any reference the importer cannot resolve to something in the archive fails the import loudly rather than silently importing a row that still points at the source tenant.
- File references. An archive names stored files (evidence attachments,
document-room items, contract documents, scheduled report artifacts and
similar). The importer copies rows, never files, so a reference is kept only
when it names an object in the target tenant's own storage. Anything else is
dropped: the field is emptied, the row is still imported, an evidence row
records the drop, and the preflight and the apply report the count per
table.field(droppedFileReferences). - Integrity: database foreign-key constraints stay enforced throughout, so a dangling reference outside the archive fails the import.
- Progress and failure reporting follows the same lifecycle as an export job (pending, processing, complete, failed).
The deletion certificate
Closing an organization produces a certificate and requires an explicit, hard-to-trigger-by-accident confirmation before it fires. It builds on the existing organization lifecycle (see the organization status guide), which is the mechanism that puts an organization into the deleted-pending state; it is not a second, parallel deletion path.
A customer's closure request is handled by a CMDash platform administrator (multi-factor authentication required), and that administrator's action produces a certificate instead of a silent status change.
The flow
POST /admin/organizations/:id/deletion-certificate/requestcreates aREQUESTEDcertificate and a single-use confirmation token (15-minute lifetime). It changes nothing else: calling it any number of times never moves the organization's status.POST /admin/organizations/:id/deletion-certificate/:certificateId/confirmrequires both the token from step 1 and the organization's slug typed back verbatim. A wrong token or slug is rejected without confirming, and is counted against a 5-attempt limit before the certificate is auto-cancelled. On success, the organization status changes exactly as it does from the plain admin status control (sessions revoked, audit row written), and the certificate is stampedCONFIRMEDwith its PDF rendered and stored. If the status transition itself fails, the certificate is rolled back toREQUESTEDwith a fresh token rather than left stuck.POST /admin/organizations/:id/deletion-certificate/:certificateId/executerecords that CMDash operations actually ran the manual purge procedure. This step does not delete any data itself.POST .../:certificateId/cancelwithdraws a still-REQUESTEDcertificate; no token is required because nothing has happened yet to undo.
Every route requires a multi-factor-authenticated platform administrator, plus the two-call token-and-slug requirement above, so a single stray authenticated call cannot delete anything.
What the certificate claims, and what it explicitly does not
It does not claim to have run an automatic data purge. A CONFIRMED
certificate's PDF says exactly that: the organization is in the deleted-pending
state (all authentication and API access is blocked), and the tenant-scoped data
enumerated under "Models in scope" has not been purged by the confirmation
itself, because CMDash does not run an automated per-tenant deletion sweep.
Removal is a manual operations procedure, and the execute step is where that
procedure's completion is recorded, after the fact.
Retained items, stated on every certificate:
- The certificate record itself survives the purge it documents. It has no link to the organization record, so it is the one record a customer or CMDash support can still point to after everything else about the organization is gone.
- Database backups and snapshots taken before the purge may persist independently for a period governed by CMDash's backup practice. The certificate does not track or control backup expiry, and does not claim a specific retention window it cannot verify against the live deployment.
- Everything else, including the organization's own audit log, is not exempt. Every table under "Models in scope" (the same list the export uses), audit logs included, is deleted by the purge once it runs. The certificate is explicit that nothing about the organization survives in the live database except itself.
Access to certificates
Certificates are tenant-scoped like every other table: the organization's own Owner can read its historical certificates, and platform administrators read and write across organizations under a platform-admin scope.