Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

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.

How-to guideLast updated

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
)

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.