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#
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 aboutDescription#
What is personal is said on the field, not here: [Classification(Personal.Contact)] string Email;
(personal data (Classification(Personal.X))). 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>()) — 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])): 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.
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. 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. 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#
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#
- personal data (Classification(Personal.X)) — saying a field is personal data
- restricted rows and purpose reads (Restriction, [PurposeRead]) — what "kept restricted" means, and purpose reads
- Legal holds — nothing a dispute needs is erased or purged while it lasts — keeping what a dispute or an audit needs
- App.Implementations<T>() — how holders are discovered
- Assert.NoTraceOf(token) — proving an erasure left nothing behind
- File.WriteAllBytes(..., about: row) — files written about a row go with it