Legal hold
A legal hold keeps one file’s bytes while a dispute, an audit or an investigation needs them. Nothing deletes a held file for good: not a person, not the trash’s retention, not version pruning. The work on the file goes on as before. This page is for backend developers, administrators and evaluators.
What a hold blocks and what it allows
Section titled “What a hold blocks and what it allows”| While a hold is in force | |
|---|---|
| Blocked | Purging the file (POST P/files/:file_id/purge), for anyone, the owner with owner_can_purge included: file_on_hold |
Deleting one of its versions (DELETE P/files/:file_id/versions/:version_no): file_on_hold |
|
Purging it from the trash: the trash’s retention and POST P/trash/empty keep it (a trashed folder that holds it stays too, so it can be restored into its place; the folder’s other files are purged) |
|
Pruning its old versions: max_versions and version_retention_days leave a held file alone |
|
| Allowed | Moving it to the trash and restoring it |
| Editing its metadata, status and visibility, moving and copying it | |
| Uploading new versions, restoring an old one, downloads, previews, share links |
A hold keeps what exists; it does not freeze the file. A file can be held in any state: available, trashed or quarantined. A file whose upload has not completed cannot be held (file_pending). A held file keeps counting against the quota, like any file.
The refusal carries the hold, so your code can tell the user why:
{ "status_code": 409, "error": "file_on_hold", "message": "…", "hold": { "reason": "Dispute 2026-17", "placed_by": "user-ana", "placed_at": "2026-10-23T09:00:00.000Z", "until": null } }The hold on the file
Section titled “The hold on the file”Every file carries hold: null, or { reason, placed_by, placed_at, until }.
| Field | Meaning |
|---|---|
reason |
Why the file is held, 1 to 500 characters. Everyone who can view the file sees it, so write it for them |
placed_by |
The user who placed it. A backend client that names acting_user_id records that member; without it, the client’s id |
placed_at |
When it was placed |
until |
When it ends by itself, or null: until someone releases it |
Placing, changing and releasing a hold changes neither the file’s revision nor its updated_at: a hold is governance, not an edit.
Who places and releases holds
Section titled “Who places and releases holds”files.manageMachine scope: files:manage| Route | Does |
|---|---|
PUT P/files/:file_id/hold |
Places a hold with { reason, until? }. On a file that is already held it replaces the reason and the end; who placed it and when stay |
DELETE P/files/:file_id/hold |
Releases it, with an optional { reason } for the audit log. A file without a hold answers unchanged |
GET P/holds |
Every held file of the application in any state, the newest hold first, placed_by= to keep one person’s holds, paged |
Holds work the same inside an application-managed folder, where people can change nothing else. Each answers the file (the list: a page of files). A user needs the files.manage permission key, from a role the tenant owner mapped; anyone else gets missing_capability. A machine client needs files:manage. No event is published for a hold; the audit log records every change.
import { FilesConflictError, type File } from '@central-code-station/ccs-files-client';import { files } from './files-client';
// Hold every file of a post while a dispute runs; your client needs files:manage.export async function holdPost(postId: string, caseRef: string, actingUserId: string): Promise<File[]> { const { items } = await files.files.listByExternalRef(`post:${postId}`); return Promise.all( items.map((file) => files.files.hold.place(file.file_id, { reason: `Dispute ${caseRef}`, // shown to everyone who can view the file until: null, // or a future ISO date: the hold then ends by itself acting_user_id: actingUserId, // recorded as placed_by }), ), );}
// Purging a held file is refused: the hold comes back in the error body.export async function purgeUnlessHeld(fileId: string): Promise<'purged' | 'held'> { try { await files.files.purge(fileId); return 'purged'; } catch (error) { if (error instanceof FilesConflictError && error.code === 'file_on_hold') return 'held'; throw error; }}
// Every held file of the application, newest hold first; then release them.export async function releaseAll(caseRef: string): Promise<void> { let after: string | undefined; do { const page = await files.holds.list({ after, limit: 100 }); const ours = page.items.filter((file) => file.hold?.reason === `Dispute ${caseRef}`); await Promise.all(ours.map((file) => files.files.hold.release(file.file_id, { reason: `${caseRef} closed` }))); after = page.next_cursor ?? undefined; } while (after);}In the Files console, the file page shows a banner on a held file (the reason, who, since when, until when) with Change the hold and Release the hold, and the application’s Legal holds page lists every held file and places new holds; see File details.
How a hold ends
Section titled “How a hold ends”- Released by someone with
files.manage. The audit rowhold.releasedkeeps the release reason and the hold itself: its reason, who placed it, when, and its end. - Expired. Once
untilhas passed the hold blocks nothing. The service clears it within a day and writeshold.releasedas the system, withreason: expired. Until then the file still shows the hold with its pastuntil, and the list still includes it.
After the end, the file is an ordinary file again: the trash’s retention and version pruning apply on their next run.
| Action | Written when |
|---|---|
hold.placed |
A hold is placed (reason, until) |
hold.updated |
The reason or the end of a hold in force changes (previous_reason, previous_until) |
hold.released |
A hold is released or expires |
Read them in the application’s audit log (GET P/audit with action=hold.placed,hold.updated,hold.released) or a file’s own trail (GET P/files/:file_id/audit). See Audit actions.
A hold also travels with the file when a platform administrator moves the tenant’s files to another storage: the bytes are copied and checked before the old copy is deleted, so a held file is relocated, never lost. See Where files are stored.