# Legal holds — nothing a dispute needs is erased or purged while it lasts

> A legal hold in `Osysharp.Privacy` keeps everything about a person — or one case — from being erased or purged while a dispute, a claim or an audit needs it. Only the people the app names place or lift one, a lift needs a reason, both are recorded, a hold has a review date, and an organisation may require a second person to confirm a lift.

<!-- id: security-legal-holds · area: security · stability: preview · html: https://osysharp.com/reference/security/legal-holds/ -->

## Summary        {#summary}
A customer disputes a refund and may go to court; the tax agency asks for last year's books. Whatever those need must
not disappear because the same customer asked to be erased, or because a period ran out. A **legal hold** says so:
`PlaceHold("ann@example.com", null, "Dispute with Ann, 2026", "May go to court", reviewBy)`. While it stands, an
erasure **waits** on it — the person is told only that the data is "kept for the establishment, exercise or defence of
legal claims", never the matter — and the daily purge leaves the held rows alone. Lifting it needs a reason; what
waited runs then.

## Signature      {#signature}
```osy syntax
policy PlacesLegalHolds;                                   // the app supplies it — an admin, legal counsel

LegalHold PlaceHold(string email, Guid? account, string matter, string reason, DateTime reviewBy);   // a person
LegalHold PlaceCaseHold(string entity, Guid row, string label, string matter, string reason, DateTime reviewBy);
void LiftHold(LegalHold hold, string reason);              // refused without a reason; recorded
void ConfirmHoldLift(LegalHold hold);                      // the second person — never the one who asked
void KeepHolding(LegalHold hold);                          // withdraw a lift that waits for a second person
void ReviewHold(LegalHold hold, DateTime next);            // keep it, and look again on `next`

bool IsHeld(DataSubject subject);                          // for a holder's own purge of ordinary rows
bool IsCaseHeld(Guid row);  List<Guid> HeldCaseRows();

// IPersonalDataHolder 0.2.0 — defaulted, so a 0.1.0 holder compiles unchanged
bool Hold(LegalHold hold, DataCategory category, bool held);

app.Privacy = new PrivacySetup { HoldLiftNeedsSecondPerson = true };   // [OrgPolicy(Lock)] — off by default
```

## Description    {#description}
**What a hold freezes.** A hold on a **person** covers every category about them: an erasure plan marks what it would
erase as waiting (`PlanLine.Held`), and runs it the day the hold is lifted — a hold placed after the plan was made
still stops it. What the person's categories keep anyway (the books) is kept restricted as ever. A hold on a **case**
covers one row an app names (`PlaceCaseHold("Order", order.Id, "Order 1042", …)`); the holder that keeps that row asks
`IsCaseHeld(row.Id)` in its `BlockedBy` and in its own purge.

**The purge never deletes a held row.** Before each category is purged, every hold in force is handed to its holder —
`Hold(hold, category, true)` — which keeps the rows the hold covers out of the purge. A row kept restricted is read only
for its purpose, so a holder keeping a category implements `Hold` with a `[PurposeRead]` function that moves each
covered row's period with `Restriction.SetUntil(row, null)`, and gives it back with `KeepUntil` when the hold is lifted.
A holder that cannot say which rows a hold covers (its `Hold` answers false, or throws) has its **whole category left
unpurged** while the hold stands, recorded every day as a deferred `PurgeRecord` naming why — never purged blind, never
skipped silently.

**Who, and how.** Only `PlacesLegalHolds` places or lifts a hold. A hold changes only through its run's events; nobody
writes or deletes a hold row — not a page, not an import, not a script. A lift needs a reason, checked in the run, and
records who and when. With `HoldLiftNeedsSecondPerson`, a lift waits for a second person who places holds, never the one
who asked; the hold stays in force until they confirm. The people managing privacy see **that** a person is held (a plan
they approve waits on it) but not the matter or the reason. An access export never mentions a hold.

**A hold is not forever.** It has a review date; on it, the hold is marked `ReviewDue` (still in force) and its review
is **work on the Inbox** (0.6.0): a `HoldReview` item, read only by `PlacesLegalHolds`, due two weeks after the date and
titled by the case it holds (never the matter). Its pane, `HoldReviewPane(item)`, shows what is held and why and settles
it — keep it with the next date (`ReviewHold`) or lift it — and either closes the item. A hold kept and reviewed again
gets a new item on its next date. A lifted hold's record is kept for the kit's own records' period
(`privacy.records`), then only the fact of it remains — no person, no matter, no reasons.

**From the record's own page.** `HoldControl(entity, row, label)` — on a ticket, an order, a candidacy — offers
"Place a legal hold…" to the people who place holds (a matter, a reason, a review date), and once placed says the record
is on hold, what for, and until when it is reviewed. To everyone else it draws nothing. A page that asks the server
once for everything a record opens with includes `CaseHoldShown(row)` — anybody may ask it, and it answers nothing to
whoever does not place holds — and hands it over as `HoldControl(…, opened:)`, which then asks nothing of its own.

**The page.** `/settings/privacy/holds` (announced in the settings area to `PlacesLegalHolds`) lists the holds in force,
places one on a person, lifts one with a reason, confirms a lift as the second person, and shows what was lifted, by whom
and why. An app takes it over by declaring its own `LegalHoldsSettings`.

## Examples       {#examples}
```osy title="vouchers kept for the books, and held for a dispute" test app=security-legal-holds
app Desk {
  use Osysharp.Identity;
  use Osysharp.WorkQueues;
  use Osysharp.Mail;
  use Osysharp.Privacy;
  use Osysharp.Workflow;
  use Osysharp.Observability;
  use Osysharp.Storage;
}

using Osysharp.Identity;
using Osysharp.Privacy;

// The kits' personal fields are read through each row's own security with nothing said here; the desk says the
// same of its OWN `BuyerEmail` (an app's own personal field it does not map is masked for everyone).
app = new() { Classifications = [new Classification(Personal.Contact) { Open = true }] };

[Role] enum DeskRole { Authenticator, Admin }
policy IsAdmin => RoleGrant.Any(g => g.Member == user && g.Role == DeskRole.Admin);
policy ManagesPrivacy => IsAdmin;
policy PlacesLegalHolds => IsAdmin;
// A request is inbox work: the Inbox (and Collaboration under it) ask who works it.
policy WorksInbox => IsAdmin;
policy LeadsInbox => IsAdmin;
policy AdministersInbox => IsAdmin;
policy ManagesOrganizations => IsAdmin;
policy CollaboratesOnRecords => IsAdmin;

entity Voucher {
  [Required, MaxLength(40)] string Number;
  DateTime IssuedAt;
  [Classification(Personal.Contact), MaxLength(320)] string BuyerEmail;
  security { allow read, create, update, delete when IsAdmin; }
}

public class DeskPersonalData : IPersonalDataHolder {
  public string Key() => "Desk";
  public List<DataCategory> Categories() => [
    new DataCategory {
      Key = "desk.vouchers", Label = "Your email address on vouchers we issued",
      Purpose = "Bookkeeping", Basis = RetentionBasis.LegalObligation, Kind = RecordKind.Bookkeeping,
      RetainedMonths = 84, RetainedFrom = RetentionStart.EndOfFiscalYearCalendarYear, MinMonths = 36, MaxMonths = 132 },
  ];
  public int Count(DataSubject s, DataCategory c) => Voucher.Count(v => s.Emails.Contains(v.BuyerEmail));
  public void Erase(DataSubject s, DataCategory c) { }
  public DateTime? Restrict(DataSubject s, DataCategory c, bool untilLifted) {
    DateTime? latest = null;
    foreach (var v in Voucher.Where(v => s.Emails.Contains(v.BuyerEmail)).ToList()) {
      DateTime? until = untilLifted ? null : KeepUntil(c, v.IssuedAt);
      Restriction.Restrict(v, c.Key, until);
      if (until != null && (latest == null || until > latest)) { latest = until; }
    }
    return latest;
  }
  public ExportSection Export(DataSubject s, DataCategory c) => new ExportSection { Category = c.Key,
    Json = JsonSerializer.Serialize(Voucher.Where(v => s.Emails.Contains(v.BuyerEmail)).Select(v => new { v.Number }).ToList()) };
  // A hold on the buyer keeps their kept vouchers out of the purge; lifting it gives them their period back.
  public bool Hold(LegalHold hold, DataCategory c, bool held) {
    HoldVouchers(SubjectOfHold(hold).Emails, hold.CaseRow, held);
    return true;
  }
}

/// Kept vouchers are read for nothing but the books — and for a hold on them. Logged like every purpose read.
[PurposeRead("desk.vouchers")]
public int HoldVouchers(List<string> emails, Guid? caseRow, bool held) {
  var n = 0;
  foreach (var v in Voucher.Where(v => emails.Contains(v.BuyerEmail) || v.Id == caseRow).ToList()) {
    DateTime? until = held ? null : KeepUntil(CategoryOf("desk.vouchers"), v.IssuedAt);
    if (Restriction.SetUntil(v, until)) { n = n + 1; }
  }
  return n;
}
```

## See also       {#see-also}
- [The Privacy kit — erasure that keeps only what the company must or may](https://osysharp.com/reference/security/privacy-kit/) — the kit: categories, requests, plans and the purge a hold freezes
- [restricted rows and purpose reads (Restriction, [PurposeRead])](https://osysharp.com/reference/security/restriction/) — what "kept restricted" means, and `Restriction.SetUntil`
- [personal data (Classification(Personal.X))](https://osysharp.com/reference/security/personal-data/) — saying a field is personal data
