Runbook โ Legal Hold โ
ADR: ADR-028Architecture:
gdpr-strategy.mdยง4 Owner: DPO + Legal (approvers); Ops (operators)
A legal hold suspends GDPR-driven scrubbing for specific records under active investigation (regulatory request, civil litigation, criminal matter). This runbook covers opening, maintaining, and closing a hold โ and triaging a scrub fault that intersects with a hold.
The backoffice.legal_holds table is the only sanctioned mechanism. Manually editing pii_redacted_at to suspend a scrub is a compliance finding.
Schema recap โ
CREATE TABLE backoffice.legal_holds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
entity_type TEXT NOT NULL CHECK (entity_type IN ('passenger','reseller','invoice','conversation')),
entity_id UUID NOT NULL,
reason TEXT NOT NULL,
until TIMESTAMPTZ, -- NULL = open-ended
created_by UUID NOT NULL REFERENCES backoffice.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
closed_by UUID REFERENCES backoffice.users(id)
);Scrub functions (scrub_passengers, scrub_resellers, scrub_invoices, scrub_conversations) read this table first and log SKIPPED_LEGAL_HOLD in tenant_scrub_logs for any hit.
Opening a hold โ
Approvers (required, two-person): DPO + Legal counsel (internal or engaged external).
- Legal opens a ticket in the
legal-holdJira project. Attach: matter reference, scope (which tenants, which entity types), expected duration, legal basis. - DPO signs off in the ticket with a timestamp.
- Ops runs the insert via the standard migration path (never via a live
psqlsession):sqlINSERT INTO backoffice.legal_holds (tenant_id, entity_type, entity_id, reason, until, created_by) VALUES (:tenant, :etype, :eid, :reason, :until_or_null, :ops_user); - Verify: the next scrub run for that entity logs
SKIPPED_LEGAL_HOLDwith the holdidas the skip_reason field. - Post a private note in
#ops-privileged(not#ops): hold opened, no matter details โ Jira link only.
Closing a hold โ
- Legal updates the Jira ticket with "release" intent and rationale.
- DPO signs off the release.
- Ops updates the record:sql
UPDATE backoffice.legal_holds SET closed_at = now(), closed_by = :ops_user WHERE id = :hold_id AND closed_at IS NULL; - The next scheduled scrub run will process any overdue records. Do not manually backdate
pii_redacted_ator directly redact โ let the scheduled pipeline handle it so the audit trail is clean. - Close the Jira ticket with the next successful scrub's log entry attached.
Scrub fault interaction โ
If a scrub job fails (alert A4 in observability-alerts.md) and the job was processing entities adjacent to an active hold, follow this sequence before retrying:
- Snapshot
tenant_scrub_logsrows for the failing run. - Verify no
SKIPPED_LEGAL_HOLDrow is mistakenly attributed to a matter that has already been closed (defensive: covers a race whereclosed_atwas set between job read and write). - Retry the scrub run. If the fault persists: open a P2 incident โ do not disable the scrub; a disabled scrub combined with active holds is a dual-compliance risk.
Audit โ
Quarterly (first Monday of Feb/May/Aug/Nov):
- Dump
backoffice.legal_holdsrows whereuntil < now() AND closed_at IS NULL. Any matches = stale holds โ escalate to DPO. - Diff the list of hold
ids againsttenant_scrub_logs.skip_reasonsamples over the past 90 days to confirm every open hold is actively being honoured by the pipeline.
Notes โ
entity_idis a UUID, never a natural key. If the same passenger migrates between tenants: file one hold pertenant_idโ scrubs are tenant-scoped.- The
untilcolumn is advisory only. Scrubs continue to skip a hold untilclosed_at IS NOT NULL, regardless ofuntil. This is deliberate so that lapsed matters do not silently auto-release. - The hold table is append-mutate; rows are never deleted (only
closed_at-stamped). This is the audit trail.