Summary#
Under the GDPR a person may ask what you hold about them, take it with them, have it restricted or erased. For most of an app's own tables the answer follows from two facts: whose data a row is, and why it is kept. Say both, and the platform does the rest:
[PersonalData(SupportNotes)] // why: a privacy category, declared once as a function
entity Ticket {
[DataSubject] Customer Customer; // whose: the customer the ticket is for
[Classification(Personal.Content)] string? RootCause;
}Signature#
DataCategory SupportNotes() => new DataCategory { Key = "app.notes", Label = "…", Purpose = "…", Basis = … };
[PersonalData(SupportNotes)] // erasure blanks the row's own Personal.* fields; the row stays
[PersonalData(SupportNotes, OnErasure.DeleteRow)] // erasure deletes the row — it is wholly the person's
[DataSubject] Person Owner; // the app's [Principal] — found by the account a request proved
[DataSubject] Ticket Ticket; // another [PersonalData] row — that row's person
[DataSubject] Customer Customer; // a type a [FindsDataSubject] class finds (a party, by its addresses)
[DataSubject(Identifier.Email)] string Email; // the person's own address — or Identifier.Phone, a number
[FindsDataSubject]
public class CustomersOf { public List<Customer> Find(DataSubject s) { … } }Description#
What the platform writes. One IPersonalDataHolder for the app (another for each package that declares its own),
discovered like every holder, keyed by the app's name. For each category:
| a request asks | the holder |
|---|---|
| what is held (every plan) | counts the rows about the person — rows already kept restricted included, read inside the category's purpose read |
| access, portability | exports each row's own values and every personal field it inherits, with when it was made |
| erasure | erases — blanks the row's Personal.* fields (OnErasure.ClearPersonalFields, the default), or deletes the row (OnErasure.DeleteRow) |
| restriction, or a kept category | keeps each row restricted until its period ends — counted from when it was made — or until lifted |
| — | a row a legal hold covers as a case makes the erasure wait (Legal holds — nothing a dispute needs is erased or purged while it lasts) |
Whose a row is. A request carries only what it proved — an account, addresses, numbers — never what a holder
finds along the way. The [DataSubject] field says how to get from those to a row: the app's [Principal] (by the
account), the person's own address or number held on the row (Identifier.Email, Identifier.Phone, matched exactly),
another [PersonalData] row (an answer through its ticket, a ticket through its customer), or a type a
[FindsDataSubject] class finds. Osysharp.Privacy.Conversations ships PartiesOfASubject, so a reference to a
Party — or the app's own Customer : Party — is followed through the addresses on its contact points, parties merged
into it included. A finder is a CLASS, not a function: a public function is callable by a request, and "who holds this
address" must not be a URL.
Which fields. What is personal stays said on the field ([Classification(Personal.X)],
personal data (Classification(Personal.X))). Erasure blanks every personal field the row holds, its own and the ones it inherits from a
base type alike.
Order. A request erases the category that says how others find the person — their contact points
(DataCategory.Identifies) — after every other one, so a declared holder still finds the tickets of a customer whose
addresses go in the same request. A declared category whose rows other declared rows are found through, and which an
erasure deletes, is marked so too: in the example below a member's calls go before the preference that says whose
they are. Within each step, the declared holder's erasure runs before every holder you wrote by hand. So a hand-written
holder that writes something in place of a blanked field keeps it. For example, a ticket's title, which was the
customer's subject, can become "Erased conversation" instead of staying blank.
Refused at compile (PERSONAL_DATA_DECLARATION), each naming the fix: a [PersonalData] entity with no — or two —
[DataSubject] fields; a reference no request can follow; a category that is not a parameterless DataCategory
function, or whose Key is not a literal (it names the category's purpose reads and access-trail rows); a required
personal field that is not text, when erasure keeps the row; a row that stays on erasure pointing at one that is
deleted (the delete would be refused); [PersonalData] in an app that does not use
Osysharp.Privacy; and — for now — an entity whose erasure keeps the row in a KEPT category (a legal basis with a
period), because a kept row is purged by deletion when its period ends.
When to write a holder instead. Anything the declaration cannot say: a reason an erasure must wait ("an order is on
its way"), a category kept with another (KeptWith), a kept complaint read only for a claim, files written about a row.
A hand-written holder and a declared one live side by side in one app.
Examples#
app Club {
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;
[Role] enum ClubRole { Authenticator, Admin }
policy IsAdmin => RoleGrant.Any(g => g.Member == user && g.Role == ClubRole.Admin);
policy ManagesPrivacy => IsAdmin;
policy PlacesLegalHolds => IsAdmin;
policy WorksInbox => IsAdmin;
policy LeadsInbox => IsAdmin;
policy AdministersInbox => IsAdmin;
policy ManagesOrganizations => IsAdmin;
policy CollaboratesOnRecords => IsAdmin;
/// Why the club keeps a member's newsletter preference.
public DataCategory Preferences() {
return new DataCategory { Key = "club.preferences", Label = "Your newsletter preference",
Purpose = "Sending you the newsletter", SubjectKind = "members",
Basis = RetentionBasis.Consent, Portable = true };
}
/// What we wrote down when a member called us — wholly about them, so an erasure deletes it.
public DataCategory CallNotes() {
return new DataCategory { Key = "club.calls", Label = "Notes from your calls to us",
Purpose = "Helping you", SubjectKind = "members", Basis = RetentionBasis.Contract };
}
[PersonalData(Preferences, OnErasure.DeleteRow)]
entity Preference {
[DataSubject(Identifier.Email), Classification(Personal.Contact), MaxLength(320)] string Email;
bool Newsletter;
[CreatedAt] DateTime CreatedAt;
security { allow read, create, update, delete when IsAdmin; }
}
[PersonalData(CallNotes, OnErasure.DeleteRow)]
entity Call {
[DataSubject] Preference Member;
DateTime At;
[Classification(Personal.Content), MaxLength(2000)] string? Notes;
[CreatedAt] DateTime CreatedAt;
security { allow read, create, update, delete when IsAdmin; }
}
// The club's own two levels, read by whoever may read the row (the admins); the kits' levels need no line.
app = new() { Classifications = [ new Classification(Personal.Contact) { Open = true },
new Classification(Personal.Content) { Open = true } ] };See also#
- The Privacy kit — erasure that keeps only what the company must or may — categories, requests, plans and the purge
- personal data (Classification(Personal.X)) — saying a field is personal data, and who reads it
- restricted rows and purpose reads (Restriction, [PurposeRead]) — what "kept restricted" means
- Legal holds — nothing a dispute needs is erased or purged while it lasts — a hold keeps a person's data, or a case, while a dispute needs it