# 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 the app holds about them: erase now, keep restricted for a stated basis until a date, or wait on a block. Every kit and the app declare what they hold (`IPersonalDataHolder`), the tenant sets the periods, and the response says what was erased, what was kept, why and until when.

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

## Summary        {#summary}
A person asks to be erased. Their account and their notes can go now; their name on an invoice must stay, because the
books must keep it for seven years — but only for the books. `use Osysharp.Privacy@0;` makes that a declaration rather
than code: each kit, and the app, implements **one contract**, `IPersonalDataHolder`, and declares what it holds as
**categories** — each with a basis (contract, legal obligation, legal claims, …) and how long a row is **kept
restricted** after a request, counted from an event. A request is **planned** per category and **runs** as a workflow;
the **response** tells the person what happened, on which basis, until when.

## Signature      {#signature}
```osy syntax
policy ManagesPrivacy;                                            // the app supplies it
policy PlacesLegalHolds;                                          // …and who places legal holds

public interface IPersonalDataHolder {                            // 0.2.0 — versioned (members added later carry defaults)
  string Key();
  List<DataCategory> Categories();
  int Count(DataSubject subject, DataCategory category);          // how many rows — never what they hold
  string BlockedBy(DataSubject subject, DataCategory category) => "";
  string KeptWith(DataSubject subject, DataCategory category) => "";   // 0.5.0 — kept beside another kept category
  void Erase(DataSubject subject, DataCategory category);
  DateTime? Restrict(DataSubject subject, DataCategory category, bool untilLifted);
  ExportSection Export(DataSubject subject, DataCategory category);
  int Purge(DataCategory category, DateTime now) => Restriction.Purge(category.Key, now);
  bool Hold(LegalHold hold, DataCategory category, bool held) => …;   // 0.2.0 — keep held rows out of the purge
}

PrivacyRequest FileRequest(RequestKind right, Guid? account, string email,
                           IdentityCheck check, string checkedHow);   // staff: say HOW you checked it is them
void ApproveRequest(PrivacyRequest r);                            // runs it
void ExtendRequest(PrivacyRequest r, int months, string reason);  // Art. 12(3) — within the first month, told to them
void RetryRequest(PrivacyRequest r);                              // a failed request, once what failed is fixed

// A person asking for themselves (0.3.0)
public interface IRequesterProof { … }                            // the sign-in kit's step-up — Osysharp.Privacy.UserAccounts
ProofStarted ProveItIsMe();  string ConfirmItIsMe(Guid proof, string code);
PrivacyAsk AskAboutMe(RequestKind right, Guid proof);              // the kit's run files the request
List<KeptCategory> WhatWeKeep();  List<KeptCategory> WhatAskingDoes(RequestKind right);
string FetchMyExport(PrivacyRequest r);                           // once, within seven days
DateTime KeepUntil(DataCategory c, DateTime itemDate);           // the end of a kept row's period, fiscal-year aware
void SetRetention(string category, int months, RetentionStart from, string basisDetail = "",
                  string jurisdiction = "", string reason = "");  // within the kit's limits, or say why
int ApplyPreset(string jurisdiction);                             // SE, DE, FR, NL, DK, FI, UK — bookkeeping
List<ProcessingRecord> RecordsOfProcessing();                     // GDPR Art. 30, derived
int PurgeNow();                                                   // the daily purge, now

// Whose data a row is, said when the work is done (0.9.0)
void MarkAbout(Guid row, string kind, DataSubject subject);       // by account, address or number — a fingerprint is kept
List<Guid> RowsAbout(DataSubject subject, string kind);           // the marked rows, and what hangs under them
public interface ISubjectLinks { List<SubjectRow> Under(Guid row, string kind); }   // discovered
bool IsMarkedHeld(Guid row, string kind);                         // a legal hold covers somebody it is about
```

## Description    {#description}
**What is personal is said on the field**, not here: `[Classification(Personal.Contact)] string Email;`
([personal data (Classification(Personal.X))](https://osysharp.com/reference/security/personal-data/)). A category says why a row is kept and for how long.

**Holders are discovered, never wired.** The kit finds every implementation in the app
([App.Implementations<T>()](https://osysharp.com/reference/class/app-implementations/)) — a holder nobody listed is still reached by every erasure.

**A plan, per category**, decided from the declarations:

| request | category | outcome |
|---|---|---|
| erasure | contract, consent, none | **erase** |
| erasure | legal obligation, legal claims (with a period) | **keep restricted** until the end of its period |
| erasure | the holder says it must wait (an order on its way) | **blocked** — the request waits and runs when it clears |
| erasure | erased on request in itself, but needed beside a kept category (`KeptWith` — who a kept complaint is from) | **keep restricted** with it, for its reason, until it ends |
| erasure | legitimate interest | **erase**, unless somebody records compelling grounds (`KeepOnCompellingGrounds`) |
| restriction | any held | **keep restricted** until the person asks to lift it |
| access / portability | held (portability: portable only) | **export** |

A kept row is restricted by the platform ([restricted rows and purpose reads (Restriction, [PurposeRead])](https://osysharp.com/reference/security/restriction/)): absent from every read but the purpose functions
the app declares (`[PurposeRead("app.vouchers")]`), whose reads are logged.

**Periods are the tenant's.** `SetRetention` refuses a period outside the kit's `MinMonths`..`MaxMonths` unless a
jurisdiction and a reason are stated — recorded, and shown in the records of processing. `ApplyPreset("SE")` sets every
bookkeeping category to Bokföringslagen's period in one action. A category that `RequiresConfirmation` is flagged until
`ConfirmCategory` is called. `KeepUntil` counts from the category's start event with the app's fiscal year
(`app.Privacy = new PrivacySetup { FiscalYearEndMonth = 6, FiscalYearEndDay = 30 }`).

**The run** fails — never finishes — when a holder throws, and names it. A finished erasure keeps the address it erased
only as a keyed fingerprint. A daily purge (`StartDailyPurge`) removes what reached the end of its period, for
everyone, and records each category's count.

**Legal holds** (0.2.0) keep everything about a person, or one case, from being erased or purged while a dispute or an
audit needs it: an erasure waits on the hold and the person is told only that the data is kept for legal claims. See
[Legal holds — nothing a dispute needs is erased or purged while it lasts](https://osysharp.com/reference/security/legal-holds/).

**A request is inbox work** (0.3.0). `PrivacyRequest : WorkItem`, routed like any item (`Kind = "privacy"` — give it a
restricted queue with a routing rule), read only by `ManagesPrivacy` and by the person it is about. Its promise is the
statutory one: `DueAt`, one calendar month from receipt, at risk and breached like any clock; `ExtendRequest` moves it
with a reason the person is told. Answering it meets the promise; a request that fails stays owed until it is retried.

**Two ways in.** Staff file one on a person's behalf (`FileRequest`) and record how they checked who it is; its plan
waits for approval, and nothing is mailed to the address they typed. A signed-in person asks on `/privacy`
(`MyPrivacy`): they prove it is them NOW — the sign-in kit's step-up, a code to the address their account holds
(`Osysharp.Privacy.UserAccounts`, which arrives by itself in an app that uses `Osysharp.UserAccounts`) — see what asking will do, and ask. The kit's own run
spends the proof and files the request about their own account and that address only; the answer goes there and to the
page, and an export is fetched once, within seven days, then deleted. The request pane for the Inbox is
`PrivacyRequestPane(id)`.

**A person with no account** (0.4.0) asks on `/privacy/ask` (`AskWithoutAnAccount`): they prove the address they wrote
from with a code mailed there — an `IAddressProof`, discovered; `Osysharp.Privacy.Conversations` implements it with the
desk's own code — and the request is about that address alone (`IdentityCheck.CodeToTheirAddress`). The proof is the
asking BROWSER's: only that visit can spend it, once. Their export reaches them by a single-use link mailed to that
address (`ExportLink`, `/privacy/export/{link}`), which the one download revokes. Staff may name a number they checked
(`FileRequest(…, phone: "+46…")`), which is what reaches the texts the app sent.

**The kits' own holders**: the mail the app sent (`mail.sent`, here — every app answering requests sends mail), and,
as adapters an app adds beside the kit they cover, `Osysharp.Privacy.Conversations` (complaints kept RESTRICTED for
legal claims with the person they are from — read only through `ConversationForAClaim` by whoever `HandlesClaims`, every
read logged — the rest erased, and purged for everyone after the service period: `KeepForMonths` by default, the
tenant's on the retention page), `Osysharp.Privacy.MailReceiving` (`mail.received`) and `Osysharp.Privacy.Sms`
(`sms.sent`). The answer to a finished erasure is written after the erasure ran, so it goes out with
`MailMessage.ForgetOnceSent`: once sent, the outbox keeps that it went and nothing of who it went to.

**An app's own rows need no holder written** (0.6.0): an entity that says whose data a row is (`[DataSubject]`) and its
category (`[PersonalData(Category)]`) gets one from the compiler — counted, exported, restricted and erased with the rest,
by name in every plan and the records of processing. See [Declared personal data — say whose a row is, and the platform answers for it](https://osysharp.com/reference/security/declared-personal-data/). A category that says HOW
other holders find the person — their addresses, their numbers — sets `Identifies = true`, and a request erases it after
every other category, so no holder loses track of them before it has done its part. Within that order, a declared
holder's erasure runs before every holder written by hand, and anything kept is put in its kept shape after everything
erased beside it. So what a hand-written holder writes on a row stays, and is not blanked after it (0.8.2). A
hand-written holder needs a key of its own: the declared one is keyed by the app's name, and a request is not planned
while two holders share a key.

**Who is responsible** (0.6.0). Every answer names the controller — the legal entity running the app — and where to
complain (`SupervisoryAuthority`). The controller is the tenant's to name, on the settings area (`/settings/privacy/controller`,
"Who is responsible", for `ManagesPrivacy`, saved with the bar); the app's code may suggest one
(`app.Privacy = new PrivacySetup { Controller = "…" }`). Until either does, nothing is invented: an answer says it comes
from "the people who run it", and `ControllerName()` answers `""`, so an app can say on its own pages that it is
missing.

**Whose data a kit's row is** (0.9.0). A field can say it — `[DataSubject] Customer Customer;` — when the app owns the
entity. A row of a kit's is about whoever the work happens to be about: an assignment about a customer's late parcel,
a piece of an agent's work about a borrower. The code that starts the work says so once —
`MarkAbout(assignment.Id, "Assignment", new DataSubject { Emails = [order.CustomerEmail] })` — by an account, an
address or a number, and a request then finds the row from what it proved (`RowsAbout`). The mark keeps a keyed
fingerprint of an address, never the address; anybody signed in may make one through `MarkAbout`, nobody changes
one, and only `ManagesPrivacy` and a request's own run read them back. What hangs under a marked row is about the
same person without a mark of its own: a holder that knows the link implements `ISubjectLinks` (discovered, like a
holder) — the work attached to an assignment, the items in its repository. The marks are a category themselves,
`privacy.marks`, which `Identifies`: a request erases them last, after every holder has found its rows through them.
A holder that purges marked rows at the end of their period asks `IsMarkedHeld` first.

**The agent family's holders** arrive by themselves beside the kits they cover: `Osysharp.Privacy.Agents` (what an
agent was shown and answered, and the record that it worked — erased while the record's hash chain still verifies),
`Osysharp.Privacy.Llm` (the model meter: who a call was made for), `Osysharp.Privacy.Assignments` (who asked, who
owns, who was on it, and what was written on it) and `Osysharp.Privacy.WorkRepositories` (what an assignment knows,
kept as items, each kind for its own time). See [Privacy for the agent family — agents, the model meter, assignments and work repositories under a person's request](https://osysharp.com/reference/agent/privacy/). The platform itself keeps no text sent to an AI
model, so it declares none.

**What a category tells the person** is said in their words: `RetainedFromWords` ("from the last message in it") when
the start event's own words are not true, and `OnErasureWords` when "Erased." is not ("You were taken off them. The
calls are still counted, on nobody's name."). The tenant's periods are readable by everyone — they are the privacy
notice.

**What is held about YOU** (0.7.0). Beside each category, the person's page says how many of it the app holds about
them right now — "We hold 2 of these about you." — never what the rows say. The numbers are each holder's own `Count`,
the same a request's plan asks, about the person as their own request would be: their account and the address it has
proven (`IRequesterProof.ProvenAddress(account)`; the Accounts adapter answers it once the address is verified). They
are counted by the kit's own run (`HeldTally`), because a person's data is often rows they may not read themselves — the
mail the app sent them. A category nobody can find by person says so (`DataCategory.FoundByPerson = false`). A request
staff file is planned the same way, by the request's run rather than with the staff member's access.

**Find a person's rows by their ACCOUNT, and by an address only when the row holds nothing else.** A subject's
`Emails` are the addresses a request has *proven* — none, in an app that proves none — so a holder that reads the
address off the account (`s.Emails.Contains(p.Member.Email)`) counts 0 and the page says "We hold none of these about
you" over rows on the person's own pages. Write `p.Member.Id == s.Account`; keep `s.Emails.Contains(v.BuyerEmail)` for a
row whose only link to a person is an address, like the voucher below. `osy lint` names the first shape
(`data-holder-finds-an-account-by-its-address`).

**A copy reads as a page** (0.7.0). An access or portability export is two files, fetched together once
(`FetchMyExport` answers both links): `your-data.json`, for another service, and `your-data.html`, the same sections for
a person — each kind with why it is kept and for how long, then its rows — rendered like a mail (`ReadableExportCopy`,
escaped, nothing runs) in the language the person asked in. A holder gives its rows readable through
`ExportSection.Items` (`ExportItem`: a date, a title, a body, named fields); a compiler-written holder fills them, and a
section with none shows its JSON. Before asking, the page says what the download will hold, kind by kind.

## Examples       {#examples}
```osy title="a desk that keeps vouchers for the books" test app=security-privacy-kit
app Desk {
  use Osysharp.Identity;
  use Osysharp.WorkQueues;
  use Osysharp.Mail;
  use Osysharp.Privacy;
  use Osysharp.Workflow;
  use Osysharp.Observability;
  use Osysharp.Storage;
  // The kit's pages — "Your privacy", the request pane, the retention and legal-hold settings — are drawn with these.
  use Osysharp.Ui;
}

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()) };
}
```

## See also       {#see-also}
- [personal data (Classification(Personal.X))](https://osysharp.com/reference/security/personal-data/) — saying a field is personal data
- [restricted rows and purpose reads (Restriction, [PurposeRead])](https://osysharp.com/reference/security/restriction/) — what "kept restricted" means, and purpose reads
- [Legal holds — nothing a dispute needs is erased or purged while it lasts](https://osysharp.com/reference/security/legal-holds/) — keeping what a dispute or an audit needs
- [App.Implementations<T>()](https://osysharp.com/reference/class/app-implementations/) — how holders are discovered
- [Assert.NoTraceOf(token)](https://osysharp.com/reference/testing/assert-no-trace-of/) — proving an erasure left nothing behind
- [File.WriteAllBytes(..., about: row)](https://osysharp.com/reference/storage/file-about/) — files written about a row go with it
