Summary#
GDPR lets a company keep data a person asked to have erased when the law or a legal claim requires it — an invoiced
buyer's name stays in the books for seven years. Kept data must then be used for that purpose and nothing else.
Restriction.Restrict(row, "shop.invoiced-buyer", until) does that: the row stays stored, and is absent from
every ordinary read. A function declared [PurposeRead("shop.invoiced-buyer")] — the bookkeeping export — still
reads it, and each such read is recorded in the access trail.
Signature#
bool Restriction.Restrict(row, string category, DateTime? until) // until: null = until it is lifted
bool Restriction.Lift(row) // only a purpose read holds the row
bool Restriction.SetUntil(row, DateTime? until) // move a RESTRICTED row's period; an ordinary one stays ordinary
int Restriction.Purge(string category, DateTime before) // deletes rows whose period ended before `before`
int Restriction.Count(string category) // how many rows are kept for it
[PurposeRead("<category>")] <function> // reads that category's restricted rowsDescription#
What "absent" means. A restricted row is not returned by a query, a count, an Any, a by-key lookup, a
collection, a traversal or a set-based update; a reference to it reads as an empty row (every field null), as a
reference to a row you may not read does; its memory entries are removed (and derived again if the restriction is
lifted); an export sink receives its personal fields as null. This holds for every principal and for the platform's
own system reads — it is not a security rule a grant could widen, it is a property of the row.
Only an entity holding personal data can be restricted — one with a field classified Personal.*
(personal data (Classification(Personal.X))), or in a hierarchy with one. Restricting anything else is refused at compile.
The stamp is the platform's. No source reads or writes it; Restrict places it, Lift removes it, Purge
deletes what has reached the end of its period. A row kept until: null is never purged — it waits for Lift.
Moving a period: SetUntil. Inside a purpose read, restricted and ordinary rows are read side by side, and nothing
says which is which — so Restrict on each would restrict the ordinary ones too. Restriction.SetUntil(row, until)
moves the end of a row's period only when the row is already restricted, and answers whether it was; an ordinary
row stays ordinary. A legal hold keeps a person's kept rows from the purge this way (until: null), and gives them
their period back when it is lifted (Legal holds — nothing a dispute needs is erased or purged while it lasts).
Purpose reads. Inside a [PurposeRead("<category>")] function — and every function it calls — reads admit the
rows restricted for that one category beside ordinary rows; another category's rows stay absent. When the run
ends, every restricted row it read is written to AccessLog with AccessType = "PurposeRead", the category, the
function and who ran it. A purpose read is never unlogged: the app must use Osysharp.Observability;, or the
function is refused before it reads anything. [PurposeRead] cannot be combined with [AllowAnonymous] or
[AuthMethod] — the trail must be able to name who read.
⚠ What a purpose function returns is ordinary data in its caller's hands. Return what the purpose needs (the PDF for the books), not the row.
Examples#
app Books { use Osysharp.Observability; }
entity Voucher {
[Required, MaxLength(40)] string Number;
decimal Amount;
[Classification(Personal.Contact), MaxLength(200)] string? Buyer;
security { allow read, create, update, delete when IsAuthenticated || IsAnonymous; }
}
/// The person asked to be erased; the voucher is kept, for the books only, until its period ends.
bool KeepForTheBooks(Voucher v, DateTime until) { return Restriction.Restrict(v, "app.vouchers", until); }
/// The bookkeeping export — the one place the kept buyer is read. Every read is logged.
[PurposeRead("app.vouchers")]
string BuyerForTheBooks(string number) { return Voucher.Single(v => v.Number == number).Buyer ?? ""; }[Test]
void A_kept_voucher_is_read_only_for_its_purpose() {
var v = new Voucher { Number = "V-1", Amount = 120m, Buyer = "Ann Book" };
UnitOfWork.Commit();
KeepForTheBooks(v, DateTime.UtcNow.AddYears(7));
UnitOfWork.Commit();
Assert.Equal(0, Voucher.Count());
Assert.Equal("Ann Book", BuyerForTheBooks("V-1"));
Assert.Equal(1, Restriction.Count("app.vouchers"));
}See also#
- personal data (Classification(Personal.X)) — the classification that makes an entity restrictable
- security { } — who may read a row at all; a restriction holds beneath it
- audit read access (app.Audit) — the access trail a purpose read is written to
- Legal holds — nothing a dispute needs is erased or purged while it lasts — a legal hold moves kept rows' periods with
SetUntil