Legal Hold
When a regulator subpoenas data or counsel issues a litigation hold, two things have to happen: nothing in scope can be deleted, and counsel needs an export they can hand to the other side. Tetrapus models both as first-class objects so an admin can open a hold in seconds and the deletion paths refuse to delete in-scope data without anyone having to remember to disable a cron.
Two tables
legal_holds tracks the matter; legal_hold_artifacts tracks every artifact the hold has touched (so an artifact known to be in scope at any point during the hold's lifetime can be re-discovered later, even if the principal filter shifts).
CREATE TABLE legal_holds (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
matter_id TEXT NOT NULL, -- customer matter number
principal_filter JSONB NOT NULL DEFAULT '{}'::JSONB, -- e.g. {"user_ids":[…], "groups":[…]}
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
released_at TIMESTAMPTZ, -- NULL = open
created_by_user_id UUID NOT NULL REFERENCES users(id)
);
CREATE TABLE legal_hold_artifacts (
id UUID PRIMARY KEY,
hold_id UUID NOT NULL REFERENCES legal_holds(id) ON DELETE CASCADE,
artifact_kind TEXT NOT NULL, -- audit_event | session | document | scim_external_id | …
artifact_id TEXT NOT NULL, -- opaque id within that kind
captured_at TIMESTAMPTZ NOT NULL DEFAULT now()
); Deletion guard
Every API that deletes principal data calls the same hook. If any open hold's principal_filter matches the target principal, the call returns 409 LegalHoldActive and the audit log records a DeletionBlocked event citing the hold id.
SDK consumers get the same guarantee — every server-side deletion API runs through the same LegalHoldGuard::check wrapper, so a custom workflow built on top of the SDK cannot accidentally bypass the hold.
Open / release lifecycle
| Action | REST | Effect |
|---|---|---|
| Open hold | POST /admin/legal-holds | Inserts row, snapshots matching artifact ids into legal_hold_artifacts |
| List holds | GET /admin/legal-holds | All holds for the Org with open/released filter |
| Export ZIP | GET /admin/legal-holds/{id}/export | Streams a ZIP of every captured artifact (NDJSON for tabular data) |
| Release | POST /admin/legal-holds/{id}/release | Sets released_at = now(); deletion guard stops matching this hold |
principal_filter shape
The filter is JSONB to stay flexible while keeping the schema stable. Every documented field is an array; the hold matches if any field matches (logical OR). An empty filter means "everyone in this Org" — useful for org-wide regulator orders.
{
"user_ids": ["7a3c…", "f12d…"],
"groups": ["finance", "execs"],
"domains": ["@example.com"],
"agent_ids": ["agent_compliance_bot"]
} Export ZIP layout
The ZIP is structured per-artifact-kind so counsel can hand off a coherent bundle. Hashes of every file are written to manifest.json alongside the matter id and the SHA-256 of the bundle itself, so the receiving party can prove integrity.
matter_2026_acme_v_globex.zip
├── manifest.json # matter_id, hold_id, generated_at, files[]
├── audit_events/
│ └── events.ndjson # one row per audit_event in scope
├── sessions/
│ └── sessions.ndjson
├── documents/
│ ├── 7a3c-invoice.pdf
│ └── f12d-contract.docx
└── README.txt # hash chain, JWKS snapshot for verification Related
- – Audit Trail — events captured into the export
- – Audit Attestation — proves exported events were not modified
- – Compliance Matrix — LegalHoldCheckCollector evidence
- – Back to Enterprise
Questions?
Reach out for help with integration, deployment, or custom domain codecs.