Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

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

policy PlacesLegalHolds => IsAdmin; · PlaceHold(email, account, matter, reason, reviewBy) · PlaceCaseHold(entity, row, label, matter, reason, reviewBy) · LiftHold(hold, reason) · bool Hold(LegalHold hold, DataCategory c, bool held) · HoldControl(entity, row, label) · HoldReviewPane(item)

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.

preview1 example compiled by CIsecurityprivacygdprkit

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("[email protected]", 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#

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#

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#

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#

Related

The Privacy kit — erasure that keeps only what the company must or may

`Osysharp.Privacy` answers a person's GDPR request — access, portability, erasure, restriction — per category of what…

restricted rows and purpose reads (Restriction, [PurposeRead])

Keep a row only for a stated purpose: Restriction.Restrict stamps it with a privacy category and the end of its period…

personal data (Classification(Personal.X))

Mark a field as personal data once, on the property, with a level of the core `Personal` enum. From then on the audit…