Export your data and delete a workspace
Request a bundle, check it against its published digests, then end a workspace with a confirmed challenge and read what was purged and kept.
On this page
Somebody asked you for a copy of everything your company keeps in TokenTriage, and then asked for one workspace to be shut down. Both are a single call away, and both answer with a document you can hand to whoever asked.
By the end of this page you will have an archive on disk whose checksum matches the one the service published for it, and a per-store report saying what has physically gone and what is still held.
Two verbs gate the work, and they are separate grants: lifecycle.export asks for a
bundle and takes its bytes, and lifecycle.delete requests the fence, reads its
progress and withdraws it before it commits. Organization owners and admins hold
both; a workspace admin holds only the first, because a deletion cannot be taken
back once it commits and every other act a workspace admin performs can. You also
need a management key and the organization and workspace ids, which
the company and access page explains.
What a bundle holds, and what it leaves out
A bundle is one .tar.gz of newline-delimited JSON, one directory per section, with
a manifest.json beside it carrying a row count and a SHA-256 for every part. There
are fourteen sections, and the export answers for all fourteen whether or not it
could fill them.
| Section | What is in it |
|---|---|
workspace |
the workspace record: name, plan, lifecycle state, instants |
applications |
application metadata |
gateways |
gateway metadata |
environments |
environment metadata |
policies |
policy revisions, nonsecret configuration only |
releases |
what was promoted where, and when |
connections |
provider connection metadata, no envelope and no usable credential |
saved_views |
saved analytics views |
feedback |
feedback records of two kinds: app feedback and reported outcomes. Each row’s kind says which, and a row with no kind is app feedback |
runs |
test run records, metadata rows. Evaluation definitions, offline evaluation runs and dataset versions share it as rows with a kind member, and a row without kind is a test run record |
comparisons |
comparison records, metadata rows. Comparison definitions, judge profiles and decision provenance share it as rows with a kind member, and a row without kind is a comparison record |
content_inventory |
every content object with its state: stored, excluded or purged |
content_bodies |
withheld: bodies stream per object instead, under content.read |
billing |
settled spend, statements and ledger references, with a retention note |
Each section reports one of three coverages, and the third exists because “we hold this and did not hand it over” and “we could not read this” are different sentences:
| Coverage | Meaning |
|---|---|
included |
the section is in the archive, with its row count and digest |
withheld |
the service holds it and did not put it in, for a stated reason |
unavailable |
the service could not produce it, for a stated reason. It does not mean the section was empty |
Four things are outside a bundle entirely. Provider credential envelopes, their
plaintext and anything that could be replayed as a credential, which is why the
connections section carries names and settings and the manifest records a
redaction count instead. Another workspace’s rows, and another organization’s:
the archive is scoped to one workspace, and the collector pages that scope. Anything
the requester could not read through the ordinary API at packaging time, because
the collector re-resolves their grants then rather than trusting the grants standing
when they asked.
Ask for a bundle and take the bytes
Three commands do the whole journey. create answers 202 with the export in
collecting, so nothing has been written when it returns and the coverage is not
yet known.
$ tokentriage cloud export create \
--base-url https://app.example.com \
--org org_7f3k9qp2mxaz --workspace t_7f3k9qp2mxaz
Read it back until it settles. The states are collecting, packaging, ready,
expired and failed; the last three are terminal, and each asks for something
different from you.
$ tokentriage cloud export get \
--base-url https://app.example.com \
--org org_7f3k9qp2mxaz --workspace t_7f3k9qp2mxaz \
--export dex_ABCDEFGHJKLM --json
Those two command lines are shown without their answers, because the answer comes from your deployment and there is no fixture of yours to paste. The document they print is the manifest the packager wrote, and that one is pinned by a golden:
{
"schema": "tt.foundation.dataexit.manifest/1",
"port_version": "data-exit/v1",
"export_id": "dex_ABCDEFGHJKLM",
"scope": { "organization_id": "org_ABCDEFGHJKLM", "workspace_id": "t_ABCDEFGHJKLM" },
"created_at": "2026-09-20T12:00:00Z",
"expires_at": "2026-09-22T12:00:00Z",
"rows": 16,
"bytes": 2755,
"sections": [
{ "section": "workspace", "coverage": "included", "dir": "sections/workspace/",
"rows": 1, "bytes": 128,
"sha256": "sha256:3bcc6e832ba2dda021ef2a816e35bc0013bd21eed56d530d195d2f835bf4bac2",
"parts": [{ "path": "sections/workspace/part-00000.ndjson", "rows": 1, "bytes": 128,
"sha256": "sha256:3bcc6e832ba2dda021ef2a816e35bc0013bd21eed56d530d195d2f835bf4bac2" }] },
{ "section": "…" },
{ "section": "comparisons", "coverage": "unavailable", "reason": "source_unavailable",
"rows": 0, "bytes": 0 },
{ "section": "content_bodies", "coverage": "withheld", "reason": "content_read_denied",
"rows": 0, "bytes": 0 },
{ "section": "…" }
],
"redactions": [
{ "section": "connections", "rule": "credential_literal", "count": 1 },
{ "section": "connections", "rule": "opaque_blob", "count": 1 },
{ "section": "connections", "rule": "secret_field", "count": 1 }
]
}
That capture is a fixture workspace of sixteen rows, so the ids and byte counts are
not anyone’s; eleven included entries with the same shape as workspace are cut
where the two "section": "…" markers stand. The three redactions rows are the
credential material the connections collector removed, counted by rule so the
removal is visible rather than silent.
Then take the bytes. The archive streams through the control plane rather than from
a pre-signed link, because a link cannot re-check content.read while the bytes are
moving. One consequence lands on you: a failure after the first byte aborts the
response, so a truncated file can arrive under a 200 that was already sent, with no
error attached. cloud export download is the verb that catches it.
$ tokentriage cloud export download --help
Download a ready bundle to a file, and verify it.
The archive streams through the control plane rather than from a presigned URL,
because a URL cannot re-check content.read at retrieval. A failure after the first
byte therefore ABORTS the response rather than appending a JSON problem to a
half-written archive — so a download can arrive TRUNCATED with no error to report.
This verb therefore checks three things and reports success only if all of them hold:
* the archive's SHA-256 and byte count, against what the export record published;
* every part the manifest names, by digest, read back out of the archive;
* and that every named part was actually there.
A failure leaves what arrived under a .partial name, says so, and exits non-zero. It
is never deleted: a partial copy you know about is worth more than a clean exit.
The single-use ticket this mints is never printed, logged or echoed.
The flags and the rest of that help text are cut after the last line shown. The ticket it mentions is minted by one call and spent by the next: it names one member of the bundle, carries no authority of its own, and lives two minutes by default with a ceiling of fifteen.
If the download refuses after the ticket was minted
A mint that succeeded and a stream that stops is the system working. The retrieval
re-resolves your grants, so a content.read withdrawn in between answers
content_read_denied and zero bytes are written. A ticket presented twice answers
download_ticket_spent and one presented late answers download_ticket_expired;
both are fixed by minting another. If the export itself has aged out you get
export_expired, and the remedy is different: ask for a new export, because the
bytes of the old one are gone.
The same journey from a program
Both SDKs ship the whole journey as an example you can read and copy:
sdk/ts/examples/export_and_delete.ts and
sdk/python/examples/export_and_delete.py. They create, poll, mint, fetch, compare
the digest, and then stop at the confirmation unless a caller passed an explicit
flag. This is the comparison, quoted from those files:
# 4. THE CHECK THIS EXAMPLE EXISTS FOR.
observed_digest = "sha256:" + hashlib.sha256(archive).hexdigest()
verified = (
bool(declared_digest)
and observed_digest == declared_digest
and len(archive) == declared_bytes
)// 4. THE CHECK THIS EXAMPLE EXISTS FOR.
const observedDigest = await sha256(archive);
const verified =
declaredDigest !== "" &&
observedDigest === declaredDigest &&
archive.byteLength === declaredBytes;Neither example opens a socket under test. Each is driven through the same fake transport the client tests use, which pins the request sequence and the digest check:
$ cd sdk/python && python -m pytest tests/test_customer_dataexit_example.py -v
platform win32 -- Python 3.14.5, pytest-9.1.1, pluggy-1.6.0
collecting ... collected 5 items
tests/test_customer_dataexit_example.py::test_it_creates_polls_verifies_then_only_mints_the_challenge PASSED [ 20%]
tests/test_customer_dataexit_example.py::test_it_refuses_to_report_success_on_a_truncated_archive_and_deletes_nothing PASSED [ 40%]
tests/test_customer_dataexit_example.py::test_it_spends_the_challenge_only_when_asked_and_reports_the_retained_list PASSED [ 60%]
tests/test_customer_dataexit_example.py::test_it_stops_without_fetching_when_the_export_failed PASSED [ 80%]
tests/test_customer_dataexit_example.py::test_it_reports_a_refusal_at_retrieval_by_its_slug PASSED [100%]
============================== 5 passed in 0.21s ==============================
That run is on Windows with Python 3.14.5, so your platform line and your timing will differ. The second test name is the one to read: a mismatch between the archive and the published digest stops the script before it asks for anything to be deleted.
What “deleted” means, store by store
Deleting a workspace is one decision asked twice. The first call mints a challenge bound to you, to that workspace and to the authority generation standing right now, and it is good for five minutes with a ceiling of fifteen. The second call spends it, and one transaction revokes the workspace’s keys and grants, raises the generation, creates the fence and the purge job, and appends the exclusion event. Every read of the workspace stops from that instant, because the control plane’s own state is what answers, not the arrival of a row somewhere downstream.
The CLI makes that round trip for you and stops without --yes:
$ tokentriage cloud workspace delete --help
End this workspace.
★ THIS CANNOT BE UNDONE ONCE THE FENCE COMMITS. One transaction revokes the
workspace's keys and grants, raises the registry generation, creates the deletion
intent and the purge job and appends the exclusion event. Every read of this
workspace refuses from that instant.
The ceremony is the server's own, in two calls: this verb asks for a CHALLENGE —
bound to you, to this workspace and to the authority generation standing now, and
expiring within minutes — and then spends it. It prints the scope it resolved
first, and it stops unless --yes was given.
--json does not skip that. It is an output format and not consent.
The remaining paragraph and the flags of that help text are cut. The dashboard’s workspace danger zone runs the same two calls and shows the same progress.
You can take the request back only before the fence commits, with
tokentriage cloud workspace deletion withdraw. An unconfirmed challenge is a token
that expires on its own, so there is nothing durable to withdraw and the answer is
not_found; after the fence, the answer is deletion_withdraw_too_late.
Exclusion is immediate. Purge is physical, bounded and reported per store, and
cloud workspace deletion get reports each leg separately as pending, excluded,
in_progress, purged or failed:
| Store | What “purged” means there |
|---|---|
postgres |
rows removed from thirty-one enumerated tables. Thirteen of those carry an append-only trigger and are reachable only through migration 0082’s guarded door, which admits this workspace’s own purge and no other caller |
object |
content entries move stored to excluded first, so retrieval stops before a byte is removed; the bodies are then deleted, orphaned objects under the workspace prefix are swept, and the export bundles go too |
clickhouse |
one delete mutation per analytics table, then a watch on system.mutations until it has converged. A mutation still running reports in_progress with its remaining parts |
The job’s own state is its least advanced store, and a failed leg outranks the rest,
so nothing reads purged because most of it did. A tier with no source to read
reports unverified with the reason rather than reporting success.
What is kept, and why
A deletion that destroyed the record of what you were charged would also destroy your ability to check it, so seventeen tables are out of the purge’s reach, and the progress document lists all of them with the reason in a sentence. These are the reason codes:
| Reason | What it covers |
|---|---|
financial_evidence_or_tombstone |
settled spend, statements and ledger references, and the record that the deletion and the export happened. Migrations 0049 and 0081 forbid the delete by trigger |
organization_scope |
rows that belong to the organization rather than to this workspace: its members, teams, invitations, grants and keys |
content_entry |
the content inventory row, which is the receipt for bytes that are gone |
ancestry_of_a_retained_content_entry |
the workspace, application, gateway and environment rows a retained content entry names |
exclusion_fence |
the exclusion event and the bridge’s position, which keep a late-arriving batch unreadable |
schema_refuses_delete |
a table whose own trigger admits no caller at all, not even this workspace’s purge |
The sentences travel with the codes and are written for a person to read. Four of them, verbatim:
| Kept | Sentence |
|---|---|
foundation_organizations |
the organization outlives the workspace. |
foundation_export_jobs |
0081 forbids DELETE by trigger. A customer who was offered an export is owed the fact that it existed. |
foundation_purge_jobs |
0081 forbids DELETE by trigger. It is this job’s own row: a purge that deleted its own record would be a deletion nobody could show happened. |
foundation_analytics_outbox_events |
the exclusion event carrying the generation the fence raised. It is what makes a late ingest batch unreadable. |
Two further entries are about storage rather than tables. Backups carry the estate’s point-in-time recovery window, and noncurrent object versions carry their bucket’s expiry; both are listed with the horizon your deployment declares, and a horizon that cannot be stated is left absent rather than filled with a date nobody can honour.
The bounds
| Bound | Value |
|---|---|
| exports in flight per workspace | one. A second while the first is collecting or packaging answers export_in_progress |
| bundle size | 2 GiB. The packager stops rather than writing a larger archive |
| export lifetime | at most 7 days from creation; when it passes the export reads expired and its bytes are purged |
| download ticket | one use, two minutes by default, fifteen at the ceiling |
| deletion challenge | five minutes by default, fifteen at the ceiling, bound to one administrator, one workspace and one authority generation |
| export scope | one workspace. There is no export of a single gateway, and no export across an organization |
| a section past its store’s page bound | the runs, comparisons and billing sections read stores that clamp a page, so a workspace holding more records than one page carries answers unavailable with the reason bound_exceeded rather than handing you a part of the section as if it were all of it |
Two limits are worth stating plainly rather than leaving you to infer them. Content
bodies are not in the archive at all, so an archive plus the content_inventory
section tells you what existed but a separate retrieval is what gives you the text.
And the run, comparison, release and draft rows in your bundle are metadata about
records that the schema otherwise treats as append-only: they leave PostgreSQL only
through the guarded door a workspace’s own purge opens, which means they survive
every ordinary delete you can issue against them, including your own.
A restore does not reinstate a deleted workspace
The question people ask next is whether a backup restore quietly brings a deleted workspace back. It would, on its own. Exclusions live in the same database a backup restores, so restoring an older copy reinstates the workspace and its stale exclusions together.
Which is why the fences, exclusion generations and credential revocations are also appended to a revocation journal held apart from that database, and why a restore replays the journal before reads reopen: the fence is re-declared, the content inventory is re-excluded page by page, the revocations are reapplied, and only then is the deployment readable again.
A drill measured on a local drill estate on 2026-09-22 ran that sequence twice. The
restore did reinstate the deleted workspace: an active registration at generation 1,
its content inventory back at stored. After the journal replay the registration was
withdrawn again at generation 2, the inventory was excluded, and no body had come
back, because the bodies had been destroyed before the backup was restored. Replay
took 39 and 44 milliseconds across the two runs, inside a restore-to-reads-reopen
window of 9.7 and 10.8 seconds, and all sixteen assertions held both times. Those
figures were measured on a local drill, on two scratch PostgreSQL databases, one
MinIO and one ClickHouse on a developer host; they are not a service level, and no
cluster restore has been exercised against them.
Read existing export and deletion progress with the SDKs
The read-only programs sdk/ts/examples/read_workspace_exit.ts and
sdk/python/examples/read_workspace_exit.py take TOKENTRIAGE_BASE_URL,
TOKENTRIAGE_MANAGEMENT_KEY, TOKENTRIAGE_ORG, and TOKENTRIAGE_WORKSPACE.
They list existing exports and read an existing deletion. They do not request,
confirm or withdraw deletion, and they never treat excluded as physically
purged. Use a management key with the required current workspace authority;
a workspace with no deletion can return the named workspace_not_fenced refusal.
The corresponding CLI reads are tokentriage cloud export list and
tokentriage cloud workspace deletion get, with explicit --org and
--workspace. Preserve the per-store progress and retained-data explanations.
A terminal failed purge needs support; an in_progress retry is a different state.