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).

SQL Schema
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.

graph LR DEL["DELETE /users/<id>"] --> CHK["check_holds(org_id, principal_id)"] CHK --> Q{"any open hold matches?"} Q -->|no| GO["proceed → cascade delete"] Q -->|yes| BLK["409 LegalHoldActive"] BLK --> AUD["audit: DeletionBlocked(matter_id)"] GO --> AUD2["audit: UserDeleted"]

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.

JSON principal_filter examples
{
  "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.

Text
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

Questions?

Reach out for help with integration, deployment, or custom domain codecs.