Administration
Projects, API keys, retention windows and user-data erasure, over the same JSON API as everything else and through the same binary's CLI. No admin console, no SQL.
Two rules run through all of it: who may ask, and nothing destructive happens until it is confirmed by name.
Who may ask
Three kinds of caller reach this surface: a project key, a person signed in
with an account, and TRACEPAD_ADMIN_TOKEN. What a key may do
is the scopes it was minted with — ingest, read, write,
any combination — so the key's column is three:
Key with ingest |
Key with read |
Key with write |
viewer |
editor |
Owner | TRACEPAD_ADMIN_TOKEN |
|
|---|---|---|---|---|---|---|---|
| Read a project and its windows | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Change its retention | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
| Create, list and revoke its keys | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| Erase a user's data in it | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
| Anything in another project | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| List every project | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| Create a project | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| Rename a project | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| Delete or restore a project | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
A key that holds write is still the administrator of its own project, so
retention changes without a restart and without the admin token. The key the
first start prints holds ingest alone and can change none of this, like any
key minted for an application; one minted for an agent holds read and can
look at it. A key with write is minted by an owner or editor signed in, or
with the admin token (Keys).
Its keys are the exception: no project key lists, mints or revokes keys,
and it is answered 403 on those three routes. A key that could mint keys
would turn one lost key into as many credentials as its finder wanted, each of
them outliving the revocation of the first. Issuing a credential is a person's
act — an owner's or an editor's, signed in — or the operator's, with the
admin token.
Creating, deleting, restoring and renaming a project need an owner or the
token, for one reason: an sk lives in application config and in CI, and a
leaked application credential must not be able to move a project in or out of
existence. A project's name is also the echo every destructive confirmation is
typed against, which is why renaming sits with them.
A server with no owner and no TRACEPAD_ADMIN_TOKEN answers those rows 403
with a message that names both, rather than 401-ing a perfectly good key.
The token creates owner accounts, so whoever holds it holds the deployment. It must be at least 32 characters, or the server refuses to start and says so; generate one rather than inventing it:
A wrong token is refused as fast as a wrong key, with no counter of failures —
the length is what makes guessing it hopeless. To keep it out of the
environment every docker inspect prints, put it in a file and name the file
with TRACEPAD_ADMIN_TOKEN_FILE instead (a mounted Docker or Kubernetes secret,
say); setting both is refused. The file must be a regular file of at most 4 KiB
holding the token alone.
A secret key declared in TRACEPAD_PROJECTS is held to the same 32
characters when the declaration creates its project: a short one stops the
start. For a project that already exists the declaration creates nothing — the
variable never changes a project's keys — so a longer value there does not
replace a short key. When the declared secret is still a live key of its
project, the start goes on and warns, every time, with the way out: mint a new
pair (below), move the applications onto it, revoke the short one, and declare
the new secret.
The token is sent in the same header as a key, so the CLI takes it as --key
or TRACEPAD_API_KEY:
The commands the CLI's help marks (admin token) — projects create, rename,
rm, restore and ls --deleted, keys, accounts and system — also read TRACEPAD_ADMIN_TOKEN, or the file
TRACEPAD_ADMIN_TOKEN_FILE names, when there is no --key and no
TRACEPAD_API_KEY: in the server's own container, where that is the
environment, tracepad keys ls just works. That is for a server on this
machine only: against another host the token is sent only if you pass it as
--key or TRACEPAD_API_KEY. A key given either way wins, and no other command
takes the token this way.
A person's session is a cookie rather than a header, and names the project it
is asking about with X-Tracepad-Project on the routes that do not carry one
in the path. See accounts.md.
Dry run by default
Every destructive endpoint changes nothing until ?confirm= echoes the
identity of what it destroys — the project's name, the user id, or the
trace id when one trace is deleted. Without it, the endpoint answers 200
with a preview:
{
"dry_run": true,
"would_delete": {"traces": 41203, "observations": 180114, "scores": 96,
"prompts": 4, "raw_batches": 812, "api_keys": 2,
"annotation_queues": 3, "annotation_items": 1408},
"oldest_ingested": "2026-03-14T08:21:00Z",
"confirm": "checkout-service",
"note": "the keys stop working immediately; the data is restorable for seven days"
}
Sending it back is what makes it happen:
curl -X DELETE -H "Authorization: Bearer $TRACEPAD_ADMIN_TOKEN" \
"localhost:4318/api/v1/projects/$ID?confirm=checkout-service"
A wrong confirm is a 400 that does nothing. An id is a string you paste; a
name is a thing you mean, so a typoed or hallucinated target cannot match — and
the echo is checked against the stored row inside the write transaction, so a
rename between the preview and the commit cannot turn a confirmed request into
a request for something else.
The CLI does both steps for you: it shows the preview and asks you to type the
name back. --yes answers that for a script — it still makes both requests and
still sends the server's own confirm value, so what it replaces is the typing,
not the check. A non-interactive run without --yes stops with the preview
on stderr and exit code 1.
Endpoints
| Method | Path | |
|---|---|---|
GET |
/api/v1/projects |
What the caller can reach: all with the token or an owner, its own with a key, its memberships with a member's session. A key's row carries key: {public_key, name, scopes} — the key itself, whatever its scopes. ?include=deleted (owner or token). ?activity=24h adds traces_24h to every row — the traces of the last 24 hours, counted the way /api/v1/stats counts them; a soft-deleted project carries 0. Any other value is a 400. |
POST |
/api/v1/projects |
Create; the secret is in the response and nowhere else. |
GET |
/api/v1/projects/{id} |
One project with its windows; for a key, with key as above. |
PATCH |
/api/v1/projects/{id} |
name (owner or token), retention_days, raw_retention_days, stats_retention_days (a key needs write). |
DELETE |
/api/v1/projects/{id} |
Soft delete; 202 with the purge date. |
POST |
/api/v1/projects/{id}/restore |
Undo it inside the grace window. |
GET |
/api/v1/projects/{id}/keys |
Public keys, their scopes, who minted each and when it was last used. Not with a project key. |
POST |
/api/v1/projects/{id}/keys |
Mint a pair: {"scopes": [...], "name": …}, the name optional. Not with a project key. |
DELETE |
/api/v1/projects/{id}/keys/{public_key} |
Revoke one. Not with a project key. |
DELETE |
/api/v1/projects/{id}/users/{user_id}/data |
Erase one user's data: a task, answered 202 or, within ?wait=, 200 (a key needs write). |
GET |
/api/v1/projects/{id}/erasures |
The project's erasures, those under way first (a key needs write). |
GET |
/api/v1/projects/{id}/erasures/{erasure_id} |
One erasure (a key needs write). |
DELETE |
/api/v1/traces/{id} |
Delete one trace — an editor's route, on the data plane, and a write key's. See below. |
DELETE |
/api/v1/traces?…&to= |
Delete every trace a listing filter matches before to, in rounds. |
GET |
/api/v1/projects/{id}/members |
Who has a role in this project. See accounts.md. |
The people who sign in, their roles and their invitations are
accounts.md, and tracepad accounts … manages them from a
terminal on this same token (cli.md).
Projects
tracepad projects ls
tracepad projects ls --deleted # with purge dates; admin token
tracepad projects show
tracepad projects create checkout-service
tracepad projects rename $ID orders-service
tracepad projects rm $ID
tracepad projects restore $ID
The four that move a project in or out of existence — create, rename, rm
and restore — take the admin token; ls and show take any project key.
The keys commands take the admin token too.
projects show run with a key names the key and what it may do:
projects create answers with the project and a fresh key pair, which holds
all three scopes. The secret is printed once, because only its hash is ever
stored:
project checkout-service created (9f2c…)
TRACEPAD_API_KEY=tp-sk-…
LANGFUSE_PUBLIC_KEY=tp-pk-…
LANGFUSE_SECRET_KEY=tp-sk-…
Deleting and restoring are described in retention.md: keys die at once, data survives seven days, the name stays reserved.
Commands that take a project accept --project <id>. Without it, a credential
that reaches exactly one project answers the question by itself — which is the
whole of it for the single-project install most deployments are.
Keys
Several key pairs can be active at once, which is what makes rotation zero-downtime. An owner or editor does it in the interface, under Settings → Project → API keys; from a terminal it takes the admin token, because no project key manages keys:
export TRACEPAD_API_KEY=$TRACEPAD_ADMIN_TOKEN
tracepad projects ls # the project's id
tracepad keys create --project $ID --scope ingest --name "checkout api" # mint the new pair
# move your SDKs onto it
tracepad keys ls --project $ID # wait until the old one goes quiet
tracepad keys rm tp-pk-old… --project $ID # revoke it
The token reaches every project, so it needs --project as soon as there is
more than one; with a single project the command finds it by itself.
Every key is minted with its scopes — one or more of ingest, read and
write (api.md) — and POST /api/v1/projects/{id}/keys
requires them:
curl -X POST -H "Authorization: Bearer $TRACEPAD_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"scopes": ["ingest"], "name": "checkout api"}' \
"localhost:4318/api/v1/projects/$ID/keys"
A missing or empty scopes, or a word that is not one of the three, is a
400 that names them; a word given twice counts once. Mint for the program
that will hold the key: ingest for a production application, read for an
agent or an MCP client, all three for the eval harness or an operator's
script. A key's scopes never change. Widening a key would widen every copy
of it, the one that got away included; to narrow or widen one, mint a new key
with the scopes you want, move the program onto it, and revoke the old — the
same steps as the rotation above. Every key that predates scopes holds all
three, and so does the key a project is created with; replacing those with
narrower ones is the rotation to do first.
Revoking a key also voids the media upload URLs it obtained (media.md): an upload the key had in transit is refused rather than landing after the key is gone.
There is never a window where ingest 401s. The secret half of a pair is
returned exactly once, at creation; the store keeps only its SHA-256 hash, so
there is nothing to show later even to an administrator. The name is optional,
at most 64 characters, free of control characters and not unique: it is there
to say which program holds
the key.
The listing says, for each key, who minted it and when it was last used:
PUBLIC KEY NAME SCOPES CREATED CREATED BY LAST USED
tp-pk-3f9a… - ingest 2026-09-01 08:00:00 server 2026-09-26 17:41:12
tp-pk-81c0… checkout api ingest 2026-09-26 17:30:05 ed@example.com (editor) never
- Created by is the account that minted it, with its email as it was then
and its standing in the project now —
owner,editor,viewer,removed(no role here any more),disabledordeleted; oradmin token; orserver, for the key of the first-start project — which holdsingestalone, since it is printed to the log — and thoseTRACEPAD_PROJECTSdeclares, which hold all three; orunknown, for a key older than this record. If a key was ever lost, theunknownkeys created after it are the ones to rotate first. - Last used is the last request the key authenticated, admitted or
refused. It is written to the database once a minute; the listing includes
uses not yet written, and a crash loses at most that minute.
nevermeans not since the server started recording it.
Taking somebody's access away — removing a membership, demoting an editor, disabling or deleting the account — revokes no key: the person who minted a key is often the one who wired production with it, and ingest stopping by surprise is the worse failure. The Keys card marks a key whose minter can no longer manage keys in the project, and deleting an account shows the keys it minted before it asks for the email. Rotate those deliberately: mint, move, revoke.
Revoking a project's last key leaves a project that cannot ingest, and so
does revoking the last key that carries ingest while read or write
keys remain. Both are allowed — an owner or editor can mint a new pair in the
interface, and so can the admin token — but both ask for the confirmation
first: the dry run asks for the project's name, and for the last ingest key
its note says that ingest stops until another key with that scope is minted. TRACEPAD_PROJECTS
does not re-add it: on restart it creates the projects it names that do not
exist and leaves an existing project's keys as they are.
Retention
See retention.md. The short version:
tracepad retention show
tracepad retention set --days 90
tracepad retention set --raw-days 14
tracepad retention set --forever --raw-follow
null — --forever for traces, --raw-follow for raw — is a value, not an
absence: it means "keep forever" and "follow the trace window", and it is the
only way to say it: a day count is bounded at 36500. A window that
grows applies immediately. A window that shrinks destroys data on the
next sweep, so it is previewed and confirmed like any other destruction, even
when nothing is old enough to be affected yet: what is being changed is the
policy.
The same PATCH /api/v1/projects/{id} carries the project's media setting,
"media": "store" (the default) or "placeholder" — whether ingest keeps the
images and files it takes out of payloads, or only a reference saying it did
not (media.md). It is not
destructive either way and needs no confirmation: tracepad retention set
--media placeholder.
Every dry run of a deletion — a window that shrinks, an erasure, a trace
deletion, a project's — and every confirmed answer names the media bodies the
project would stop holding as media and their decoded bytes as
media_bytes: the bodies none of the project's traces or raw batches staying
behind points at. The figure is the project's own, like the one
GET /api/v1/system reports: a body another project also holds is counted,
although its bytes stay on disk for that project, and a project's deletion no
longer says how much of what it counts leaves the disk.
Erasing a user's data
The echo is the user id. The erasure takes the traces filed under it with everything hanging off them, the scores on their sessions, the dataset items cut from them and their spans in the raw OTLP bodies (retention.md has the whole list). The dry run counts each before anything happens:
{
"dry_run": true,
"would_delete": {
"traces": 12, "observations": 240, "scores": 30, "session_scores": 2,
"annotation_items": 1, "dataset_items": 3, "media": 4, "media_bytes": 812003
},
"oldest_ingested": "2026-06-01T09:14:02Z",
"affected_runs": [{"id": "…", "dataset": "support-golden", "traces": 2}],
"affected_datasets": [{"dataset": "support-golden", "items": 3}],
"raw": {"batches_to_scan": 41, "unattributable_batches": 0},
"confirm": "user-4711",
"note": "the user's spans are removed from the raw batches that hold them …"
}
session_scores— scores given to one of the user's sessions rather than a trace; a session another user's traces share loses them too.dataset_itemsandaffected_datasets— the items cut from the user's traces, every version of each: an item that is a verbatim copy of their input and output goes with its history, and each dataset's version advances by one (datasets.md).affected_runs— erasure outranks the pin a run puts on its traces, and the run shows those items as missing afterwards (datasets.md).raw.batches_to_scan— the raw batches received while the user's traces were arriving, which the erasure decodes and checks for the user's spans.raw.unattributable_batches— the batches older than the trace window, which may hold spans of traces the sweep already took and which nothing can attribute to the user any more (retention.md). It is an estimate, not an exact count: it counts every batch received before the window's edge, including ones whose traces a run still keeps, and misses the few later batches of a trace that was open across the edge. Counting exactly would mean decoding the whole archive.
While an erasure of the user is under way, the dry run says so before its
counts, which are shrinking under it: "running": {"id": "4f0c…", "state":
"running", "phase": "parsed"}, and tracepad users rm-data prints an
erasure of this user is running: 4f0c… (parsed).
Confirmed, an erasure is a task. Everything that can refuse it — the
role or the key's scope, a deleted project, a wrong echo, a server that is
not taking writes — refuses first and records nothing; then the erasure is
recorded and the answer is 202 Accepted, with the erasure as the body and a
Location to read it at. ?wait=<seconds>, up to 30, holds the answer that
long for the erasure to end, and answers 200 with the same body when it did:
a user of a few traces is erased in one round trip. The interface waits 20
seconds, tracepad users rm-data 30, and both follow a longer one to its end
(--no-wait prints its id instead). A second request for a user whose
erasure is queued or running answers that erasure rather than starting
another.
{
"id": "4f0c9d3e8a1b2c3d4e5f60718293a4b5",
"state": "done",
"phase": null,
"user_id": "user-4711",
"dry_run": false,
"created_at": "2026-09-26T10:02:09Z",
"started_at": "2026-09-26T10:02:09Z",
"finished_at": "2026-09-26T10:02:11Z",
"progress": {"traces_at_start": 12, "traces_deleted": 12},
"deleted": {
"traces": 12, "observations": 240, "scores": 30, "session_scores": 2,
"payloads": 480, "annotation_items": 1, "dataset_items": 3,
"media": 5, "media_bytes": 901442,
"raw_spans": 252, "raw_batches_rewritten": 38, "raw_batches_deleted": 3
},
"compaction": {"requested_at": "2026-09-26T10:02:11Z", "expected_by": "2026-09-26T11:00:00Z"},
"pre_migration_backup": {"created_at": "2026-09-24T08:00:00Z", "remove_after": "2026-10-01T08:00:00Z"},
"error": null
}
state—queued,running,doneorfailed;phase—raw,parsedortailwhile it runs, the three steps below.progress—traces_at_startis how many traces the user had when the erasure first read them, andtraces_deletedhow many are gone so far.deletedcounts what has been committed so far, and is the whole once the erasure isdone.user_id— the erasure's record holds the user id only while it is queued or running. When it ends, the id is cleared in the same statement, and from then onuser_idisnulleverywhere but in the answer to the request that started it. The record itself goes 30 days after the end.error— why afailederasure failed: the phase and a cause from a fixed list, such asthe parsed phase failed: the disk is full. An erasure that ran out of starts says3 starts ended before the erasure did, followed by; its tail last failed with:and a cause when a tail failed. The record never carries an error's own text, so it cannot name the user. Nor does the erasure's log: its lines never carry an error's text, only its cause, type and position — the erasure's id, the phase, the raw batch when it is one's, the Go types of the error (types=*json.SyntaxError) and SQLite's code (sqlite=13) when there is one. That is what there is to go on; there is no setting that gives the text.
GET /api/v1/projects/{id}/erasures/{erasure_id} reads one erasure, and
GET /api/v1/projects/{id}/erasures the project's hundred, those under way
first and then the newest
— both for an editor, or a key with write, like the erasure itself.
tracepad users erasure <id> and tracepad users erasures print them.
raw_spans,raw_batches_rewritten,raw_batches_deleted— the user's spans taken out of the raw archive, and the batches that held them: rewritten without them, or deleted, when nothing else was in them or a rewrite failed. A rewritten batch is markedscrubbed_at(export.md).mediacounts the pictures only those spans pointed at as well.compaction— the freed space is zeroed as the rows go; the search index and the write-ahead log are rewritten by a sweeper pass, andexpected_byis when that pass is due: the next one, or the one after a pass already running.completed_atis when the pass that covered this erasure finished,nulluntil it has and for as long as the erasure runs; once it is set,expected_byisnull. It is the erasure's own:GET /api/v1/system's compaction stamps are the deployment's, and only the admin token or an owner reads them. All three arenullwhen the erasure found nothing to delete and so asked for nothing.pre_migration_backup— present while the server keeps a copy of the database from before its last upgrade: the one copy an erasure does not rewrite.remove_afteris seven days after it was written; the first sweeper pass after that removes it — not before, and not while the server is down. The dry run names it too.
What it does not take. What no id links to the user — a name typed into someone else's prompt, an item copied without its source — the raw batches older than the trace window, and the pre-migration backup until its date. retention.md lists each.
The annotation-queue items pointing at the erased traces go with them
(annotation.md) — an item is a pointer, and the queues keep
their shape with shorter lists. Both the dry run and the answer count them
under annotation_items, beside the traces and the scores. The verdicts
already given are scores on those traces and go with the traces.
The user's rows in the per-user rollup (users.md) go with the
first chunk of their traces, outright rather than by recomputation: they are about the user,
and for an hour past the trace-retention window there would be nothing left to
recompute them from. So the account leaves /api/v1/users immediately, and
GET /api/v1/users/{id} answers 404. The project-wide statistics are
corrected where they can be, which is the rule
retention.md states.
One erasure runs at a time on a server; the others wait, oldest first. The raw archive goes first: the batches that hold the user's spans are found, decoded and rewritten one writer job each, before the parsed rows go, because once those are gone nothing names the batches any more. The parsed rows then go in chunks — up to five hundred traces, taken in the order they started, whole hours at a time — each one a transaction that leaves the store consistent on its own: the chunk's traces go and the hours they occupied are recomputed in the same commit, each hour once (an hour holding more than five hundred of the user's traces once per chunk it spans), and the writer is held for one chunk at a time so ingest keeps flowing between them. A chunk takes as many hours as are light to recompute; a dense hour is a chunk of its own. 20 000 traces spread over a month are about sixty chunks, and seconds rather than minutes. Last, the tail: the raw batches that arrived for the erased traces while the erasure ran are rewritten too.
Nobody has to wait for it. A closed tab or a client that gives up loses the
answer and nothing else, and the erasure's record says where it is. A stop
of the server interrupts it without waiting: every job commits its progress
with it, so the next start resumes the erasure from its phase — including
the tail, which a repeat of the request could not finish, because the traces
that named those batches are gone. A clean stop does not count against the
erasure; a crash does, and so does a run that failed where the next could
succeed — a tail that could not be read, an end that could not be written,
retried a minute later, after every other erasure waiting. After three of those the next start only rewrites
those batches and ends the erasure failed rather than trying for ever, with
what the tail last failed with in its error if it did. A job that meets a
full write queue or a condition of the database — a full disk, a lock that
did not clear — is retried for up to two minutes; any other failure ends the
erasure failed, after the tail has run for the chunks that committed. Repeating
the request then finds what is left and finishes it.
Deleting traces
tracepad traces rm 4f8c1d2e3a5b6c7d8e9f0a1b2c3d4e5f
tracepad traces rm --to 2026-09-17T14:02:17Z --env loadtest --since 24h
A trace leaves the store in exactly two ways otherwise: the retention sweep
takes it when its window runs out, or the project goes whole. In between is
where the mistakes live — an eval harness that exported under the
application's key, a load test run against production, one trace holding
something a person should not have typed — and this is the door for those.
A deletion also voids the media upload URLs for the traces it removes, and
refuses new ones, for the hour such a URL lives, so a picture in transit cannot land after its trace
is gone.
One trace by id, or every trace a listing filter matches:
DELETE /api/v1/traces/{id}, and DELETE /api/v1/traces?<filters>&to=. An
editor's route rather than an administrator's — it is the reader's and the
manager's gesture at the surfaces where they read and choose — and it wears
the same ceremony as everything above: a dry run until confirm echoes the
identity of what goes. The SDKs call both from a script, the rounds walked for
you — Python, Node,
Go.
What goes is what erasure takes, by the same path: the trace, its
observations, scores, payloads and search entries, the
annotation-queue items pointing at it, and the hour it
started in re-rolled in the same transaction, so the
statistics stop counting it as the 200
arrives. Raw OTLP bodies are not touched — a raw batch holds many traces
of many kinds, and a trace cannot be cut out of one — and the preview says so
in its note. A trace an eval run holds is deleted like any other; the run
then shows the item as missing, and the preview names the run under
affected_runs so the hole is seen before it opens
(datasets.md).
One trace. The echo is the trace id, because the id is the trace's only
identity. Without confirm the endpoint answers the preview — would_delete
with traces, observations, scores and annotation_items, oldest_ingested,
affected_runs, confirm, note — and an unknown id is 404, dry run and
confirmed alike. With ?confirm=<id> it deletes and answers
{"dry_run": false, "deleted": {…}, "id", "compaction"}; deleted counts the
payloads too, and compaction says when the sweeper pass that overwrites what
the deletion unlinked is due. That pass compacts the whole store, however
little was deleted — on a large one, a rewrite of the search index; deletions
within one interval share it (retention.md).
By filter. The filters are the trace listing's own — the same names, the
same validation, 400 on an unknown one — so "delete what I am looking at"
is one call with no second grammar; an empty filter is every trace before
to. to is required, dry run and confirmed alike: the listing is
half-open on it, so a trace that starts after it can never qualify, and the
operator who previewed a thousand does not delete a thousand and forty
because ingest kept flowing in between. The CLI does not fill it in — a
script that deletes by filter should say what it means — and the interface
pins it to the moment its dialog opened, and says so. The preview counts the
match exactly under matched (the listing's own count stops at a
thousand), and the echo is the project name, the one retention shrinking
already uses for the same act by another door.
A confirmed request deletes one round: the newest limit matches
(1–1000, default 1000), in chunks of whole hours — at most five hundred
traces, and as many hours as are light to recompute — each chunk its own
transaction, and at most fifty such chunks, whichever bound comes first, so a
set spread thinly over many dense hours does not run past the interface's
clock — and answers
{"dry_run": false, "deleted": {…}, "more": true} — repeat the same call
while more is true. A request that ran for minutes is one the interface's
thirty-second clock would cut off every time, leaving the operator with an
error over a store that is in fact fine; a round is a complete, consistent
act, and the client — the CLI, the dialog — is the one that loops and can say
2,000 of 12,000. A round cut off between chunks destroys nothing half-way:
what committed is gone and counted, and the next request continues. Nothing
is recounted on the way: a confirmed round takes what the filter matches at
that moment, so a set that shrank since the preview — retention, another
operator, an earlier round — is not a reason to refuse, and a repeat after
more: false deletes nothing and is not an error.
The ingest race is documented, not fought. A deletion removes what the
store holds at that moment. A span that arrives afterwards for a deleted
trace creates the trace again from what arrived, exactly as it would for a
trace never seen; nothing is remembered about deleted ids. to= makes this
rare for the bulk form, and an operator deleting a trace whose spans are
still arriving has a stopwatch problem, not a store problem: wait for the
export to finish, then delete.
Not here: moving traces between projects (replay the
raw bodies into the other project with its key), a trash or an
undo (a project has a grace window because a project is a thing with a name
and a history; a trace is a row, and a re-export recreates it), deleting one
observation. A session's traces are ?session_id=…&to=.
In the web interface
Delete… sits on the trace header beside Add to queue, and on the traces listing beside Add to queue… — editors only, like both — each a dialog around the same card with the server's dry run on screen (ui.md).
The same card sits on the user's own page (/users/{id} → Erase data), with
the id already filled in; on success the page leaves for the listing. It is
the Settings card with one field fewer — one contract, rendered twice.
The Settings screen renders this whole contract (see ui.md):
a Project tab with the project's own management, and a Server tab —
owners only — with the project lifecycle and the accounts. Every destructive
card is the dry run above, shown, with the same echo typed into a field: the
browser is not a softer path to destruction than curl.
TRACEPAD_ADMIN_TOKEN is not entered in the interface at all. It was there
because one credential unlocked the management plane in a browser; an owner
account is that credential now, and the token stays what it is
for — the CLI, and the recovery when every owner's password is lost
(cli.md).
Not on the MCP surface
None of this is reachable as an MCP tool, and that is structural rather than an oversight. An agent should not hold destructive capability at all, so that a hallucinated tool call has nothing to destroy. See mcp.md.