Skip to content

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.

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 } }

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.

Permission: 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.

legal-hold.ts
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.

  • Released by someone with files.manage. The audit row hold.released keeps the release reason and the hold itself: its reason, who placed it, when, and its end.
  • Expired. Once until has passed the hold blocks nothing. The service clears it within a day and writes hold.released as the system, with reason: expired. Until then the file still shows the hold with its past until, 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.