# 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, and from then on it is absent from every read — staff, admins and the platform's own system reads included — except inside a function declared [PurposeRead("<category>")], whose every read of it is written to the access trail.

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

## Summary        {#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      {#signature}
```osy syntax
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 rows
```

## Description    {#description}
**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))](https://osysharp.com/reference/security/personal-data/)), 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](https://osysharp.com/reference/security/legal-holds/)).

**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       {#examples}
```osy title="a voucher kept for the books" test app=security-restriction
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 ?? ""; }
```

```osy title="absent everywhere but the purpose read" run app=security-restriction
[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       {#see-also}
- [personal data (Classification(Personal.X))](https://osysharp.com/reference/security/personal-data/) — the classification that makes an entity restrictable
- [security { }](https://osysharp.com/reference/security/entity-security/) — who may read a row at all; a restriction holds beneath it
- [audit read access (app.Audit)](https://osysharp.com/reference/config/audit/) — the access trail a purpose read is written to
- [Legal holds — nothing a dispute needs is erased or purged while it lasts](https://osysharp.com/reference/security/legal-holds/) — a legal hold moves kept rows' periods with `SetUntil`
