Skip to content

Commit 20ef9a4

Browse files
authored
Legal hold + retention label (#334)
1 parent 81b87b4 commit 20ef9a4

29 files changed

Lines changed: 5495 additions & 44 deletions

File tree

docs/enterprise/legal-holds/api.md

Lines changed: 454 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Legal Holds: User Interface Guide
2+
3+
The legal holds management interface is located at **Dashboard → Compliance → Legal Holds**. It provides a complete view of all configured holds and tools for creating, applying, releasing, and deactivating them. Per-email hold controls are also available on each archived email's detail page.
4+
5+
## Overview
6+
7+
Legal holds suspend all automated and manual deletion for specific emails, regardless of any retention labels or policies that might otherwise govern them. They are the highest-priority mechanism in the data lifecycle and are intended for use by compliance officers and legal counsel responding to litigation, investigations, or audit requests.
8+
9+
## Holds Table
10+
11+
The main page displays a table of all legal holds with the following columns:
12+
13+
- **Name:** The hold name and its UUID displayed underneath for reference.
14+
- **Reason:** A short excerpt of the hold's reason/description. Shows _"No reason provided"_ if omitted.
15+
- **Emails:** A badge showing how many archived emails are currently linked to this hold.
16+
- **Status:** A badge indicating whether the hold is:
17+
- **Active** (red badge): The hold is currently granting deletion immunity to linked emails.
18+
- **Inactive** (gray badge): The hold is deactivated; linked emails are no longer immune.
19+
- **Created At:** The date the hold was created, in local date format.
20+
- **Actions:** Dropdown menu with options depending on the hold's state (see below).
21+
22+
The table is sorted by creation date in ascending order.
23+
24+
## Creating a Hold
25+
26+
Click the **"Create New"** button above the table to open the creation dialog. New holds are always created in the **Active** state.
27+
28+
### Form Fields
29+
30+
- **Name** (Required): A unique, descriptive name. Maximum 255 characters.
31+
Examples: `"Project Titan Litigation — 2026"`, `"SEC Investigation Q3 2025"`
32+
- **Reason** (Optional): A free-text description of the legal basis for the hold. Maximum 2 000 characters. This appears in the audit log and is visible to other compliance officers.
33+
34+
### After Creation
35+
36+
The hold immediately becomes active. No emails are linked to it yet — use Bulk Apply or the individual email detail page to add emails.
37+
38+
## Editing a Hold
39+
40+
Click **Edit** from the actions dropdown to modify the hold's name or reason. The `isActive` state is changed separately via the **Activate / Deactivate** action.
41+
42+
## Activating and Deactivating a Hold
43+
44+
The **Deactivate** / **Activate** option appears inline in the actions dropdown. Changing the active state does not remove any email links — it only determines whether those links grant deletion immunity.
45+
46+
> **Important:** Deactivating a hold means that all emails linked *solely* to this hold lose their deletion immunity immediately. If any such emails have an expired retention period, they will be permanently deleted on the very next lifecycle worker cycle.
47+
48+
## Deleting a Hold
49+
50+
A hold **cannot be deleted while it is active**. Attempting to delete an active hold returns a `409 Conflict` error with the message: _"Cannot delete an active legal hold. Deactivate it first..."_
51+
52+
To delete a hold:
53+
1. **Deactivate** it first using the Activate/Deactivate action.
54+
2. Click **Delete** from the actions dropdown.
55+
3. Confirm in the dialog.
56+
57+
Deletion permanently removes the hold record and, via database CASCADE, all `email_legal_holds` link rows. The emails themselves are not deleted — they simply lose the protection that this hold was providing. Any other active holds on those emails continue to protect them.
58+
59+
## Bulk Apply
60+
61+
The **Bulk Apply** option (available only on active holds) opens a search dialog that lets you cast a preservation net across potentially thousands of emails in a single operation.
62+
63+
### Search Fields
64+
65+
- **Full-text query:** Keywords to match against email subject, body, and attachment content. This uses Meilisearch's full-text engine with typo tolerance.
66+
- **From (sender):** Filter by sender email address.
67+
- **Start date / End date:** Filter by the date range of the email's `sentAt` field.
68+
69+
At least one of these fields must be filled before the **Apply Hold** button becomes enabled.
70+
71+
### What Happens During Bulk Apply
72+
73+
1. The system pages through all Meilisearch results matching the query (1 000 hits per page).
74+
2. Each hit's email ID is validated against the database to discard any stale index entries.
75+
3. New hold links are inserted in batches of 500. Emails already linked to this hold are skipped (idempotent).
76+
4. A success notification shows **how many emails were newly placed under the hold** (already-protected emails are not counted again).
77+
5. The exact search query JSON is written to the audit log as GoBD proof of the scope of protection.
78+
79+
> **Warning:** Bulk Apply is a wide-net operation. Review your query carefully — there is no per-email confirmation step. Use the search page first to preview results before applying.
80+
81+
### Bulk Apply and the Audit Log
82+
83+
The audit log entry for a bulk apply contains:
84+
- `action: "BulkApplyHold"`
85+
- `searchQuery`: the exact JSON query used
86+
- `emailsLinked`: number of emails newly linked
87+
- `emailsAlreadyProtected`: number of emails that were already under this hold
88+
89+
## Release All Emails
90+
91+
The **Release All** option (available when the hold has at least one linked email) removes every `email_legal_holds` link for this hold in a single operation.
92+
93+
> **Warning:** This immediately lifts deletion immunity for all emails that were solely protected by this hold. Emails with expired retention periods will be deleted on the next lifecycle worker cycle.
94+
95+
A confirmation dialog is shown before the operation proceeds. On success, a notification reports how many email links were removed.
96+
97+
## Per-Email Hold Controls
98+
99+
### Viewing Holds on a Specific Email
100+
101+
On any archived email's detail page, the **Legal Holds** card lists all holds currently applied to that email, showing:
102+
- Hold name and active/inactive badge
103+
- Date the hold was applied
104+
105+
### Applying a Hold to a Specific Email
106+
107+
In the Legal Holds card, a dropdown lists all currently **active** holds. Select a hold and click **Apply**. The operation is idempotent — applying the same hold twice has no effect.
108+
109+
### Removing a Hold from a Specific Email
110+
111+
Each linked hold in the card has a **Remove** button. Clicking it removes only the link between this email and that specific hold. The hold itself remains and continues to protect other emails.
112+
113+
> **Note:** Removing the last active hold from an email means the email is no longer immune. If its retention period has expired, it will be deleted on the next lifecycle worker cycle.
114+
115+
### Delete Button Behaviour Under a Hold
116+
117+
The **Delete Email** button on the email detail page is not disabled in the UI, but the backend will reject the request if the email is under an active hold. An error toast is displayed: _"Deletion blocked by retention policy (Legal Hold or similar)."_
118+
119+
## Permissions Reference
120+
121+
| Operation | Required Permission |
122+
| -------------------------------- | ------------------- |
123+
| View holds table | `manage:all` |
124+
| Create / edit / delete a hold | `manage:all` |
125+
| Activate / deactivate a hold | `manage:all` |
126+
| Bulk apply | `manage:all` |
127+
| Release all emails from a hold | `manage:all` |
128+
| View holds on a specific email | `read:archive` |
129+
| Apply / remove a hold from email | `manage:all` |
130+
131+
## Workflow: Responding to a Litigation Notice
132+
133+
1. **Receive the litigation notice.** Identify the relevant custodians, date range, and keywords.
134+
2. **Create a hold**: Navigate to Dashboard → Compliance → Legal Holds and click **Create New**. Name it descriptively (e.g., `"Doe v. Acme Corp — 2026"`). Add the legal matter reference as the reason.
135+
3. **Bulk apply**: Click **Bulk Apply** on the new hold. Enter keywords, the custodian's email address in the **From** field, and the relevant date range. Submit.
136+
4. **Verify**: Check the email count badge on the hold row. Review the audit log to confirm the search query was recorded.
137+
5. **Individual additions**: If specific emails not captured by the bulk query need to be preserved, open each email's detail page and apply the hold manually.
138+
6. **When the matter concludes**: Click **Deactivate** on the hold, then **Release All** to remove all email links, and finally **Delete** the hold record if desired.
139+
140+
## Troubleshooting
141+
142+
### Cannot Delete Hold — "Cannot delete an active legal hold"
143+
**Cause:** The hold is still active.
144+
**Solution:** Use the **Deactivate** option from the actions dropdown first.
145+
146+
### Bulk Apply Returns 0 Emails
147+
**Cause 1:** The search query matched no documents in the Meilisearch index.
148+
**Solution:** Verify the query in the main Search page to preview results before applying.
149+
**Cause 2:** All Meilisearch results were stale (emails deleted from the archive before this operation).
150+
**Solution:** This is a data state issue; the stale index entries will be cleaned up on the next index rebuild.
151+
152+
### Delete Email Returns an Error Instead of Deleting
153+
**Cause:** The email is under one or more active legal holds.
154+
**Solution:** This is expected behavior. Deactivate or remove the hold(s) from this email before deleting.
155+
156+
### Hold Emails Count Shows 0 After Bulk Apply
157+
**Cause:** The `emailCount` field is fetched when the page loads. If the bulk operation was just completed, refresh the page to see the updated count.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Legal Holds
2+
3+
The Legal Holds feature is an enterprise-grade eDiscovery and compliance mechanism designed to prevent the spoliation (destruction) of evidence. It provides **absolute, unconditional immunity** from deletion for archived emails that are relevant to pending litigation, regulatory investigations, or audits.
4+
5+
## Core Principles
6+
7+
### 1. Absolute Immunity — Highest Precedence in the Lifecycle Pipeline
8+
9+
A legal hold is the final word on whether an email can be deleted. The [Lifecycle Worker](../retention-policy/lifecycle-worker.md) evaluates emails in a strict three-step precedence pipeline:
10+
11+
1. **Step 0 — Legal Hold** ← this feature
12+
2. Step 1 — Retention Label
13+
3. Step 2 — Retention Policy
14+
15+
If an email is linked to **at least one active** legal hold, the lifecycle worker immediately flags it as immune and stops evaluation. No retention label or policy can override this decision. The `RetentionHook` mechanism also blocks any **manual deletion** attempt from the UI — the backend will return an error before any `DELETE` SQL is issued.
16+
17+
### 2. Many-to-Many Relationship
18+
19+
A single email can be placed under multiple holds simultaneously (e.g., one hold for a litigation case and another for a regulatory investigation). The email remains immune as long as **any one** of those holds is active. Each hold-to-email link is recorded independently with its own `appliedAt` timestamp and actor attribution.
20+
21+
### 3. Active/Inactive State Management
22+
23+
Every hold has an `isActive` flag. When a legal matter concludes, the responsible officer deactivates the hold. The deactivation is instantaneous — on the very next lifecycle worker cycle, emails that were solely protected by that hold will be evaluated normally against retention labels and policies. If their retention period has already expired, they will be permanently deleted in that same cycle.
24+
25+
A hold **must be deactivated before it can be deleted**. This requirement forces an explicit, auditable act of lifting legal protection before the hold record can be removed from the system.
26+
27+
### 4. Bulk Preservation via Search Queries
28+
29+
The primary use case for legal holds is casting a wide preservation net quickly. The bulk-apply operation accepts a full Meilisearch query (full-text search + metadata filters such as sender, date range, etc.) and links every matching email to the hold in a single operation. The system pages through results in batches of 1 000 to handle datasets of any size without timing out the UI.
30+
31+
### 5. GoBD Audit Trail
32+
33+
Every action within the legal hold module — hold creation, modification, deactivation, deletion, email linkage, email removal, and bulk operations — is immutably recorded in the cryptographically chained `audit_logs` table. For bulk operations, the exact `SearchQuery` JSON used to cast the hold net is persisted in the audit log as proof of scope, satisfying GoBD and similar evidence-preservation requirements.
34+
35+
## Feature Requirements
36+
37+
The Legal Holds feature requires:
38+
39+
- An active **Enterprise license** with the `LEGAL_HOLDS` feature enabled.
40+
- The `manage:all` permission for all hold management and bulk operations.
41+
- The `read:archive` permission for viewing holds applied to a specific email.
42+
- The `manage:all` permission for applying or removing a hold from an individual email.
43+
44+
## Use Cases
45+
46+
### Active Litigation Hold
47+
48+
Upon receiving a litigation notice, a compliance officer creates a hold named "Project Titan Litigation — 2026", applies it via a bulk query scoped to a specific custodian's emails and a date range, and immediately freezes those records. The audit log provides timestamped proof that the hold was in place from the moment of creation.
49+
50+
### Regulatory Investigation
51+
52+
A regulator requests preservation of all finance-related communications from a specific period. The officer creates a hold and uses a keyword + date-range bulk query to capture every relevant email in seconds, regardless of which users sent or received them.
53+
54+
### Tax Audit
55+
56+
Before an annual audit window, an officer applies a hold to all emails matching tax-relevant keywords. The hold is released once the audit concludes, and standard retention policies resume.
57+
58+
### eDiscovery Case Management
59+
60+
Holds can optionally be linked to an `ediscovery_cases` record (`caseId` field) to organise multiple holds under a single legal matter. This allows all holds, emails, and audit events for a case to be referenced together.
61+
62+
## Architecture Overview
63+
64+
| Component | Location | Description |
65+
| --------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- |
66+
| Types | `packages/types/src/retention.types.ts` | `LegalHold`, `EmailLegalHoldInfo`, `BulkApplyHoldResult` types |
67+
| Database Schema | `packages/backend/src/database/schema/compliance.ts` | `legal_holds` and `email_legal_holds` table definitions |
68+
| Service | `packages/enterprise/src/modules/legal-holds/LegalHoldService.ts` | All business logic for CRUD, linkage, and bulk operations |
69+
| Controller | `packages/enterprise/src/modules/legal-holds/legal-hold.controller.ts` | Express request handlers with Zod validation |
70+
| Routes | `packages/enterprise/src/modules/legal-holds/legal-hold.routes.ts` | Route registration with auth and feature guards |
71+
| Module | `packages/enterprise/src/modules/legal-holds/legal-hold.module.ts` | App-startup integration and `RetentionHook` registration |
72+
| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/legal-holds/` | SvelteKit management page for holds |
73+
| Email Detail | `packages/frontend/src/routes/dashboard/archived-emails/[id]/` | Per-email hold card in the email detail view |
74+
| Lifecycle Guard | `packages/backend/src/hooks/RetentionHook.ts` | Static hook that blocks deletion if a hold is active |
75+
76+
## Data Model
77+
78+
### `legal_holds` Table
79+
80+
| Column | Type | Description |
81+
| ------------ | -------------- | --------------------------------------------------------------------------- |
82+
| `id` | `uuid` (PK) | Auto-generated unique identifier. |
83+
| `name` | `varchar(255)` | Human-readable hold name. |
84+
| `reason` | `text` | Optional description of why the hold was placed. |
85+
| `is_active` | `boolean` | Whether the hold currently grants immunity. Defaults to `true` on creation. |
86+
| `case_id` | `uuid` (FK) | Optional reference to an `ediscovery_cases` row. |
87+
| `created_at` | `timestamptz` | Hold creation timestamp. |
88+
| `updated_at` | `timestamptz` | Last modification timestamp. |
89+
90+
### `email_legal_holds` Join Table
91+
92+
| Column | Type | Description |
93+
| --------------------- | ------------- | --------------------------------------------------------------- |
94+
| `email_id` | `uuid` (FK) | Reference to `archived_emails.id`. Cascades on delete. |
95+
| `legal_hold_id` | `uuid` (FK) | Reference to `legal_holds.id`. Cascades on delete. |
96+
| `applied_at` | `timestamptz` | DB-server timestamp of when the link was created. |
97+
| `applied_by_user_id` | `uuid` (FK) | User who applied the hold (nullable for system operations). |
98+
99+
The table uses a composite primary key of `(email_id, legal_hold_id)`, enforcing uniqueness at the database level. Duplicate inserts use `ON CONFLICT DO NOTHING` for idempotency.
100+
101+
## Integration Points
102+
103+
### RetentionHook (Deletion Guard)
104+
105+
`LegalHoldModule.initialize()` registers an async check with `RetentionHook` at application startup. `ArchivedEmailService.deleteArchivedEmail()` calls `RetentionHook.canDelete(emailId)` before any storage or database DELETE. If the email is under an active hold, the hook returns `false` and deletion is aborted with a `400 Bad Request` error. This guard is fail-safe: if the hook itself throws an error, deletion is also blocked.
106+
107+
### Lifecycle Worker
108+
109+
The lifecycle worker calls `legalHoldService.isEmailUnderActiveHold(emailId)` as the first step in its per-email evaluation loop. Immune emails are skipped immediately with a `debug`-level log entry; no further evaluation occurs.
110+
111+
### Audit Log
112+
113+
All legal hold operations generate entries in `audit_logs`:
114+
115+
| Action | `actionType` | `targetType` | `targetId` |
116+
| -------------------------------- | ------------ | --------------- | ----------------- |
117+
| Hold created | `CREATE` | `LegalHold` | hold ID |
118+
| Hold updated / deactivated | `UPDATE` | `LegalHold` | hold ID |
119+
| Hold deleted | `DELETE` | `LegalHold` | hold ID |
120+
| Email linked to hold (individual)| `UPDATE` | `ArchivedEmail` | email ID |
121+
| Email unlinked from hold | `UPDATE` | `ArchivedEmail` | email ID |
122+
| Bulk apply via search | `UPDATE` | `LegalHold` | hold ID + query JSON |
123+
| All emails released from hold | `UPDATE` | `LegalHold` | hold ID |
124+
125+
Individual email link/unlink events target `ArchivedEmail` so that a per-email audit search surfaces the complete hold history for that email.

0 commit comments

Comments
 (0)