# audit read access (app.Audit)

> `app.Audit` configures the app's audit trails — WHO may read each one, and whether it is recorded at all. An app that opts into `Osysharp.Observability` keeps five trails on by default (entity changes, classified reads, workflow transitions, sign-ins, schedule occurrences); an app that does not opt in has no trail and no audit schema at all. Each read-surface (e.g. `EntityAuditRecord`) maps to an `AuditSurface` whose `Read = user => <predicate>` compiles to a deny-by-default read policy, and whose `Enabled = false` turns that trail off (audit is the developer's choice). A multi-tenant app names its tenant root ONCE with `Scope = <Entity>`, which makes `Read` ROW-aware — a predicate that touches the trail's own `Scope` column (stamped at write time) is scoped per row, not merely per principal.

<!-- id: config-audit · area: config · stability: stable · html: https://osysharp.com/reference/config/audit/ -->

## Summary        {#summary}
`app.Audit` configures your app's **audit trails** — the records the platform keeps of what happened. You never write
to a trail; `app.Audit` does two things: it gates **read** access (who may see a trail) and controls **capture**
(whether a trail is recorded at all). Each read-surface — such as `EntityAuditRecord`, the per-entity change record —
maps to a `new AuditSurface { … }` carrying an optional `Read = user => <predicate>` (a boolean over the `[Principal]`
bound as `user`) and an optional `Enabled = <bool>`. In an app that carries the capability ([[#capability]]), absent
an `app.Audit` entry a surface is **on** (audit is on by
default) and **deny-by-default for reads** (no one may read it until a `Read` predicate opens it).

```osy syntax
app.Audit = new AuditConfig {
  EntityAuditRecord = new AuditSurface { Read = user => user.IsAuditor },
  WorkflowAuditRecord = new AuditSurface { Enabled = false },   // turn a trail off
};
```

## Signature      {#signature}
```osy
app.Audit = new AuditConfig {
  EntityAuditRecord = new AuditSurface {          // one entry per surface
    Read      = user => user.IsAuditor,           // (optional) who may read the trail — predicate over the [Principal]
    Enabled   = true,                             // (optional) whether the trail is recorded — default true
    Retention = TimeSpan.FromDays(365),           // (optional) how long rows are kept — default forever
  },
};
```

`app.Audit` is a single `AuditConfig`. Each named surface (e.g. `EntityAuditRecord`) is an `AuditSurface` whose `Read`
decides who may read that trail, `Enabled` decides whether it is captured, and `Retention` decides how long its rows are
kept. All three members are optional; a surface with none is the default (on, closed to reads, kept forever).

## Description    {#description}
The audit trails are **not something you build** — the platform records them on its own. There are five, each a named
read-surface: **`EntityAuditRecord`** (entity create/update/delete), **`AccessAuditRecord`** (reads of classified data),
**`WorkflowAuditRecord`** (workflow transitions), **`AuthAuditRecord`** (sign-in events), and
**`ScheduleOccurrenceRecord`** (what a schedule did, or did not do, each time it came due). What an app spent on which
model is not one of them: `Osysharp.Llm` keeps that itself, one row per attempt — see [Model spend — budgets, the meter and the price book (Llm kit)](https://osysharp.com/reference/agent/llm-spend/). `app.Audit` configures them:

- **`AuditConfig`** — the top-level value assigned to `app.Audit`. It holds one entry per surface you want to configure,
  plus the app-wide `Redact` policy.
- **`AuditSurface`** — a surface's config, carrying two optional members:
  - **`Read = user => <predicate>`** — who may read the trail. The predicate **binds the `[Principal]` as `user`** and
    **must be a boolean** over its fields (e.g. `user.IsAuditor`); a field it reads must exist on the `[Principal]`, or
    it is a compile error naming what the principal does define. It may name the app's own policies
    (`Read = user => IsAuditor`), which are inlined as an entity's `when` inlines them, and its names resolve in the
    file it was written in. It lowers to a **deny-by-default read policy plus an
    `allow read when <predicate>` rule**, enforced by the **same security runtime as every other entity** — no separate
    audit-permission path. A surface you never give a `Read` stays closed; one you do opens only to accepting principals.
  - **`Enabled = <bool>`** — whether the trail is captured at all. See [below](#enabled).
  - **`Retention = <TimeSpan>`** — how long rows are kept before the platform purges them. See [below](#retention).

### How does an Auditor of one tenant not see another tenant's trail?     {#scope}
`Read = user => <predicate>` binds only the **principal** — it can say *"is this user an Auditor somewhere"*, never
*"an Auditor of THIS organisation, for THIS row"*, because there is no row in scope. In a multi-tenant app that is a
real gap: an Auditor of one organisation, reading a colleague who also belongs to another, would be handed that
colleague's OTHER organisation's own change trail too — the platform-written record carries no tenant column for the
app to filter by.

`app.Audit.Scope` closes it with **one declaration**: name your app's tenant-root entity, once —

```osy title="name the tenant root once, and every record gets stamped with it" syntax
app.Audit = new AuditConfig {
  Scope = Organization,   // this app's tenant root, said once — a bare entity name, like AgentConfig.Loop
};
```

— and the platform does the rest. At WRITE time, for every entity change it captures, it looks for an unambiguous
chain of **required** entity references from the changed row to your declared root (the changed row's own type
counts too) and stamps the result into that record's own `Scope` column — no extra query, because the row is already
loaded for the change itself. A `Read` predicate that then reads `Scope` (bare, like any other column on the trail)
becomes **row-aware** automatically — no new syntax, the same distinction [security { }](https://osysharp.com/reference/security/entity-security/) already draws
between a `where` (per row) and a `when` (per principal):

```osy title="an Auditor of one organisation, reading a colleague who belongs to two" test app=config-audit-scope
using Osysharp.Observability;

entity Organization {
  [Required, MaxLength(120)] string Name;
  security { allow read where RoleGrant.Any(g => g.Organization.Id == Id && g.User == user); }
}

[Principal] entity Staff {
  [Required, MaxLength(200)] string Name;
}

entity RoleGrant {
  [Required] Staff User;
  [Required] Organization Organization;
  bool IsAuditor;
  security { allow read where User == user; }
}

entity Order {
  [Required] Organization Organization;
  [Required, MaxLength(40)] string Code;
}

app.Audit = new AuditConfig {
  Scope = Organization,
  EntityAuditRecord = new AuditSurface {
    // `Scope` (bare) is THIS record's own stamped column — an Auditor of the SAME organisation the changed
    // `Order` belonged to, never any other's.
    Read = user => RoleGrant.Any(g => g.User == user && g.IsAuditor && g.Organization.Id == Scope)
  },
};
```

- **One root, structurally derived — never guessed.** The platform never infers a tenant from a field's NAME (a
  property called `Organization` is not, by itself, a signal); it only follows a property whose declared TYPE is
  your named root, through as many `[Required]` hops as it takes. Two DIFFERENT required paths to the root make the
  chain ambiguous on purpose — the platform refuses to pick one for you, and that entity's `Scope` stays null.
- **A null `Scope` is excluded, never open.** A record with no provable tenant (an entity with no path to the root,
  or a trail with no natural tenant at all — `AuthAuditRecord`'s sign-ins are a platform event, not an
  organisation's) compares FALSE against every real tenant id, by ordinary SQL null semantics. It is never "visible
  to everyone" — an app that genuinely wants to admit those rows says so explicitly (`... || Scope == null`).
- **A delete is stamped too.** Its tenant is read from the row as it stood before the delete, so deleting rows —
  including a row and the one it reaches its tenant through, together in one unit of work (an order and its lines) —
  commits as it would without `Scope`, and each `Delete` record carries its tenant.
- **Only `EntityAuditRecord` is stamped today.** The other five surfaces keep their existing principal-only `Read`;
  `Scope` is additive; a surface you never mention behaves exactly as it did before this section existed.

### Turning a trail off: `Enabled`     {#enabled}
Every trail is **on by default**. `Enabled = false` on a surface turns that one off — the platform stops recording it:

```osy syntax
app.Audit = new AuditConfig {
  WorkflowAuditRecord = new AuditSurface { Enabled = false },   // don't record workflow transitions
  AuthAuditRecord     = new AuditSurface { Enabled = false },   // don't record sign-ins
};
```

- **Per-surface.** Each surface is switched independently; omit a surface (or set `Enabled = true`) to keep
  it on. Disabling `EntityAuditRecord` turns off the **whole** entity-change trail; for finer control, exclude a single
  entity with the `[Audit(None)]` attribute on its declaration.
- **`Read` and `Enabled` are independent.** `Read` governs who may see an existing trail; `Enabled` governs whether the
  trail is written. They can be combined (`new AuditSurface { Read = user => user.IsAuditor, Enabled = false }`) or used
  alone.
- **Auditing is the developer's choice** — including the sign-in trail. Turning `AuthAuditRecord` off is a declared
  decision not to capture sign-ins; it does not weaken the guarantee that, **while on**, the trail is complete and
  tamper-proof (host-written, read-only to your app).

### Redaction: what never appears in the trail     {#redaction}
The audit trail records **entity changes** — for each changed property, its old and new value. Some values must never
be captured: a credential (`PasswordHash`, `ResetToken`) or a `[Classification]`-restricted field would otherwise land,
**unmasked**, in a durable, exportable log. `app.Audit.Redact` declares — **explicitly** — what to hold back:

- **`Redact = new AuditRedaction { … }`** — the app-wide redaction policy. Optional; declare it once.
- **`Properties = [Entity.Prop, …]`** — these exact properties are redacted wherever an audit record would capture
  their value.
- **`Classifications = [Enum.Member, …]`** — any property carrying one of these `[Classification]` levels is redacted.
  This names the **same** classification levels the app declares for read-masking, so audit-redaction and read-masking
  cannot drift.

A redacted property **still appears** in the change record — the "it changed" signal is preserved — but its old and new
values are replaced with the marker `‹redacted›`, never the raw value. Redaction is **declared, never inferred**: a
property is redacted only if named here (directly, or via its classification). An app that declares no `Redact` captures
values verbatim.

### How long rows are kept: `Retention`     {#retention}
By default a trail's rows are kept **forever**. `Retention = <TimeSpan>` sets a window; the platform periodically purges
that surface's rows older than the window. Use the standard C# `TimeSpan` factories:

```osy syntax
app.Audit = new AuditConfig {
  EntityAuditRecord = new AuditSurface { Retention = TimeSpan.FromDays(365) },   // keep a year of changes
  AccessAuditRecord = new AuditSurface { Retention = TimeSpan.FromDays(90) },    // keep 90 days of access logs
};
```

- **Per-surface, constant.** Each surface has its own window; a surface with no `Retention` is kept forever. The window
  is a compile-time constant — `TimeSpan.FromDays(…)` / `FromHours(…)` / `FromMinutes(…)` / `FromSeconds(…)` over a
  literal (not a per-row expression).
- **Enforced by a platform sweep.** A platform-owned background job runs periodically (per app) and deletes each
  surface's expired rows — you declare the window; the platform does the deleting. It is best-effort and eventually
  consistent: rows are removed on the next sweep after they age out, not at the exact instant.

### Who signed in, and who failed to? `AuthAuditRecord`     {#auth-trail}
Beyond entity changes, the platform records **system-access events** — who logged in, who FAILED to, and who logged
out — on the `AuthAuditRecord` surface. Every sign-in path routes its outcome through one funnel that writes the trail,
so it is complete across providers by construction: the platform-managed password and OAuth flows, and an app's OWN
sign-in (an `[AuthMethod]` function that calls `Security.IssueJwt`) — a successful `Security.IssueJwt` records a
`LoginSucceeded` for you. Each row carries the `Event`
(`LoginSucceeded` / `LoginFailed` / `SignupSucceeded` / `Logout`), the `AttemptedLogin` (the username tried — the
brute-force / account-enumeration signal, recorded even for a failed unknown-user attempt), the `AuthMethod`, a
`FailureReason` on a failure, and the client info. Like every trail it is **host-written and read-only** to your app,
gated by `app.Audit.AuthAuditRecord.Read`, and — like every trail — subject to `Enabled` (see [above](#enabled)): an app
may decline to record sign-ins with `AuthAuditRecord = new AuditSurface { Enabled = false }`.

### Why didn't anything run last night? `ScheduleOccurrenceRecord`     {#occurrence-trail}
A [Schedule (recurring work)](https://osysharp.com/reference/scheduling/schedule/) produces work on a cadence whether or not anyone is watching, so the question people ask it
is usually about the times it produced **nothing**. The `ScheduleOccurrenceRecord` surface answers that: one row for
every occurrence the platform considered, carrying when it was `DueAt`, when the platform looked (`At`), the `Outcome`
(`Produced` / `Skipped` / `Retired` / `UnknownTarget`), the row it produced, and `CoalescedCount` — how many further
occurrences a catch-up absorbed.

Every consideration is recorded, **the ordinary ones included**, and that is what gives an absent row a meaning: no
record for last night means the platform never looked, which is a different fault from a night that was skipped. A
trail holding only the exceptions leaves those two indistinguishable.

Unlike the other five, this trail's producer is a **cadence rather than a person** — a minutely schedule writes some
half a million rows a year — so it is the one most worth a `Retention` window:

```osy syntax
app.Audit = new AuditConfig {
  ScheduleOccurrenceRecord = new AuditSurface {
    Read      = user => user.IsOnCall,
    Retention = TimeSpan.FromDays(90),
  }
};
```

### Is my app audited at all? `use Osysharp.Observability;` decides     {#capability}
The audit trail is a **capability**, `Osysharp.Observability`, and **an app that does not opt into it has no audit
trail** — no schema, no rows, nothing recorded. Measured: a bare app's database holds two platform tables, and
neither is an audit table.

⚠ **This page said the opposite until 2026-09-23** — *"its schema is always present … whether or not your app opts
in"* — and that was true when the capability was seeded into every app. It changed deliberately (owner: *"osyrin
has ZERO opinions on if someone is auditing their app or not … the opinion should come from either the single app
developer or the org"*). An audit trail nobody asked for still costs a row per change, for ever. **If you are
relying on being audited, check that one of the two lines below is in your app** — do not infer it from the
platform.

Two ways in, and either is enough to put the schema in your database:

- **`use Osysharp.Observability;`** in the `app { }` manifest declares the dependency outright.
- **writing `app.Audit = new AuditConfig { … }`** brings it in on its own: configuring auditing IS asking for it,
  so the compiler composes the capability rather than making you say it twice.

Then, separately, to NAME the records in your own queries:

- **`using Osysharp.Observability;`** at the top of a source file imports the audit read-surface names, so a function or
  query in that file may reference `EntityAuditRecord` / `AccessAuditRecord` / `AuthAuditRecord`.

`ScheduleOccurrenceRecord` needs no `using` either — but it is **not** free: it comes from
`Osysharp.Scheduling`, so the app must `use` that capability, exactly like the others above. Without it the name does
not resolve at all. (This page said a schedule was "core rather than a capability" until 2026-09; that was true, and
stopped being true when the scheduling runtime moved out of the core baseline into a capability of its own.)

`app.Audit` (above) then decides **who** may read what you thus reference — the capability opens the names to your
code; the `Read` predicates open the rows to a principal. See [use](https://osysharp.com/reference/types/use/) for how `use` and `using` differ.

### How do you read a trail? {#querying}
Once `Read` accepts a principal, that principal's code queries the trail exactly like any other read-only entity —
the friendly surface name (`EntityAuditRecord`, `AccessAuditRecord`, …) is a normal queryable set: [Where / Single / Count](https://osysharp.com/reference/query/where/),
[OrderBy / ThenBy](https://osysharp.com/reference/query/ordering/), `Take`/`Skip`, `Select`, `Count`, `Any`, [ToList](https://osysharp.com/reference/query/tolist/). `new`, an assignment, or `.Delete()` on
a trail is a compile-time refusal — the trail is host-written, never app-written.

Filter it exactly as you would a domain entity — most often by the row it is about (`EntityId`) or the person who
made the change (`UserId`):

```osy title="the last 20 changes to one row" test app=config-audit-query
using Osysharp.Observability;

[Principal] entity Staff {
  [Required, MaxLength(200)] string Name;
  bool IsAuditor;
}

entity Order {
  [Required, MaxLength(200)] string Code;
  decimal Total;
}

app.Audit = new AuditConfig {
  EntityAuditRecord = new AuditSurface { Read = user => user.IsAuditor }
};

List<string> RecentChangesTo(Order order) {
  var rows = EntityAuditRecord
    .Where(e => e.EntityType == "Order" && e.EntityId == order.Id)
    .OrderByDescending(e => e.Timestamp)
    .Take(20)
    .ToList();
  return rows.Select(e => (e.UserName ?? "system") + " " + e.Action).ToList();
}
```

`EntityType` is the changed entity's own name, as a string (`"Order"`) — one trail holds every entity's changes, so
there is no typed FK back to the row it describes. `EntityId` is that row's id; join it back to a live row yourself
(as above) when you need one, and expect the join to come back empty when the row was later deleted — the trail
outlives what it describes.

A lighter, **ungated** alternative answers the narrower "who touched this row, and when" without an `AuditSurface`
grant at all: `<Entity>.Changes(id)` returns field NAMES only, never values, so it is readable by anyone who may read
the row itself — see [Changes](https://osysharp.com/reference/entity/changes/). Reach for the full trail above when you need the actual old/new values, or a
query spanning more than one row.

To SHOW the trail rather than query it, `use Osysharp.AuditLog@0;` mounts a default audit log page among the app's
settings, reading exactly the rows your `Read` rule gives each reader — see [The AuditLog kit — the app's audit log, as a page of its settings](https://osysharp.com/reference/ui/audit-log-kit/).

### What fields does each trail expose? {#fields}
Each surface below is the friendly name your code queries — see [use](https://osysharp.com/reference/types/use/) for the `using` that brings a name
into scope. Every one of them, `ScheduleOccurrenceRecord` included, needs its capability `use`d by the app first.

**`EntityAuditRecord`** — one row per entity create/update/delete, written by the platform post-commit:

| Field | Type | Meaning |
|---|---|---|
| `EntityType` | `string` | the changed entity's own name |
| `EntityId` | `Guid` | the changed row's id |
| `Action` | `string` | `Create` / `Update` / `Delete` |
| `UserId` | `Guid` | who made the change |
| `UserName` | `string?` | their display name, snapshotted at the time of the change |
| `ActingAgent` | `string?` | the agent name, when the change was agent-driven |
| `Timestamp` | `DateTime?` | when the change was committed |
| `Changes` | `Json?` | property deltas — `{ propertyName: { old: value, new: value } }`, redacted per [above](#redaction) |
| `FunctionName` | `string?` | the function that caused the change (null for a direct API mutation) |
| `CorrelationId` | `string?` | the request's correlation id, for end-to-end tracing |
| `WorkflowInstanceId` | `Guid?` | set when the change was workflow-driven |
| `Source` | `string?` | the origin channel — MCP, RestAPI, Function, Designer, Agent |
| `ClientInfo` | `string?` | the calling MCP client's self-reported name/version, when the source was MCP |
| `OnBehalfOfUserId` | `Guid?` | set when the actor acted for someone else (a scheduled run, a delegated credential) |

**`AccessAuditRecord`** — one row per read of an entity carrying a `[Classification]`-restricted property:

| Field | Type | Meaning |
|---|---|---|
| `EntityType` | `string` | the entity that was read |
| `EntityId` | `Guid` | the row that was read |
| `UserId` | `Guid` | who read it |
| `UserName` | `string?` | their display name, snapshotted at the time of the read |
| `Timestamp` | `DateTime?` | when the read happened |
| `AccessType` | `string` | how it was read — Query, GetById, API |
| `ClassifiedProperties` | `Json` | the names of the classified properties that were visible in the response |
| `CorrelationId` | `Guid` | the request's correlation id, for end-to-end tracing |

**`WorkflowAuditRecord`** — the widened lifecycle timeline for a workflow run:

| Field | Type | Meaning |
|---|---|---|
| `Instance` | `WorkflowRun` | the run this event belongs to |
| `EventKind` | `int` | what happened — Transitioned/Claimed/Released/Assigned/Cancelled/Reminded/Breached/Held/Resumed/Retargeted/Refused/Deposited/Orphaned/Retired/Faulted, coded 0–14 in that order |
| `FromState` / `ToState` | `Guid` | the states either side of the transition — valid only within the version this event was written under; read the names below instead |
| `FromStateName` / `ToStateName` | `string?` | the state names, recorded at write time, so the timeline survives a run migrating to a newer version |
| `Slot` | `Slot` | the slot this event concerns |
| `Actor` | `Guid?` | who acted, when there was an acting principal |
| `Reason` | `string?` | a structured reason code or short reason |
| `Message` | `string?` | a free-text message |
| `At` | `DateTime` | when the event happened |
| `OldBudgetTicks` / `NewBudgetTicks` | `long?` | the run's budget before/after, on a re-target event |
| `ConsumedTicks` | `long?` | ticks consumed at the moment of this event |
| `Payload` | `Json?` | structured event payload, for the event kinds that carry one |
| `PreviousAssignee` | `Guid?` | who held the slot immediately before, on a claim/assignment that took it from someone else |
| `AssignmentCount` | `int?` | the slot's assignment count AFTER this event, so churn is countable straight off the trail |

**`AuthAuditRecord`** — one row per sign-in event:

| Field | Type | Meaning |
|---|---|---|
| `Event` | `string` | `LoginSucceeded` / `LoginFailed` / `SignupSucceeded` / `Logout` |
| `Outcome` | `string` | `Succeeded` or `Failed` |
| `FailureReason` | `string?` | why a failed attempt failed (null on success) |
| `UserId` | `Guid?` | the authenticated/created user — null when the attempt is for an unknown user |
| `AttemptedLogin` | `string?` | the username/email the caller ATTEMPTED — recorded even for a failed unknown-user attempt |
| `AuthMethod` | `string` | how the caller authenticated — password, oauth, apikey |
| `Provider` | `string?` | the OAuth identity provider, when `AuthMethod` was oauth |
| `Timestamp` | `DateTime?` | when the event occurred |
| `ClientInfo` | `string?` | client identity — IP + user-agent, best-effort |
| `CorrelationId` | `string?` | the request's correlation id, for end-to-end tracing |

**`ScheduleOccurrenceRecord`** — one row per occurrence the platform considered (see [above](#occurrence-trail)):

| Field | Type | Meaning |
|---|---|---|
| `Schedule` | `Schedule` | the schedule this occurrence belongs to |
| `DueAt` | `DateTime` | the instant this occurrence was due |
| `At` | `DateTime` | when the sweep considered it — on a catch-up this can be long after `DueAt` |
| `Outcome` | `ScheduleOccurrenceOutcome` | `Produced` / `Skipped` / `Retired` / `UnknownTarget` |
| `ProducedRow` | `Guid?` | the row this occurrence created, on `Produced` |
| `CoalescedCount` | `int` | how many FURTHER occurrences this one absorbed by re-arming past them — 0 on an ordinary occurrence |
| `Note` | `string?` | why, in words, when `Outcome` alone does not say it |

## Examples       {#examples}
An app whose audit trail is readable only by staff flagged as auditors. The `[Principal]` declares the `IsAuditor`
field the predicate reads:

```osy title="basic" test app=config-audit-example
using Osysharp.Observability;

[Principal] entity Staff {
  [Required, MaxLength(200)] string Name;
  bool IsAuditor;
}

app.Audit = new AuditConfig {
  EntityAuditRecord = new AuditSurface { Read = user => user.IsAuditor }
};
```

Redaction alongside the read gate: the account's password hash (by its `Secret` classification) and its API key (named
directly) are recorded as `‹redacted›`, never verbatim, while ordinary fields are captured as-is:

```osy title="redaction" test app=config-audit-redaction
using Osysharp.Observability;

enum DataClass { Public, Secret }

[Principal] entity Staff {
  [Required, MaxLength(200)] string Name;
  bool IsAuditor;
}

entity Account {
  [Required, MaxLength(200)] string Login;
  [MaxLength(200)] [Classification(DataClass.Secret)] string PasswordHash;
  string ApiKey;
}

app.Audit = new AuditConfig {
  EntityAuditRecord = new AuditSurface { Read = user => user.IsAuditor },
  Redact = new AuditRedaction {
    Properties      = [Account.ApiKey],       // this exact property
    Classifications = [DataClass.Secret]      // any property at the Secret level (Account.PasswordHash)
  }
};
```

Turning trails off is per-surface and independent of the read gate. Here workflow-transition auditing is disabled
outright, while the entity-change trail stays on but readable only by auditors:

```osy title="enabled" test app=config-audit-enabled
using Osysharp.Observability;
using Osysharp.Workflow;

[Principal] entity Staff {
  [Required, MaxLength(200)] string Name;
  bool IsAuditor;
}

app.Audit = new AuditConfig {
  EntityAuditRecord   = new AuditSurface { Read = user => user.IsAuditor },
  WorkflowAuditRecord = new AuditSurface { Enabled = false }
};
```

Retention windows keep the trail bounded — a year of entity changes, ninety days of access logs, sign-ins forever:

```osy title="retention" test app=config-audit-retention
using Osysharp.Observability;

[Principal] entity Staff {
  [Required, MaxLength(200)] string Name;
  bool IsAuditor;
}

app.Audit = new AuditConfig {
  EntityAuditRecord = new AuditSurface { Read = user => user.IsAuditor, Retention = TimeSpan.FromDays(365) },
  AccessAuditRecord = new AuditSurface { Retention = TimeSpan.FromDays(90) }
  // AuthAuditRecord has no Retention → sign-ins are kept forever
};
```

## See also       {#see-also}
- [principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads](https://osysharp.com/reference/security/principal-predicates/) — the `user => …` predicate form, and the `[Principal]` it binds
- [security { }](https://osysharp.com/reference/security/entity-security/) — the deny-by-default read policy + `allow read when` rule this lowers to
- [role grants (and the first admin)](https://osysharp.com/reference/security/role-grants/) — granting a principal the role a predicate can test
- [use](https://osysharp.com/reference/types/use/) — `use Osysharp.Observability;` (the dependency) and `using Osysharp.Observability;` (the import)
- [Schedule (recurring work)](https://osysharp.com/reference/scheduling/schedule/) — the recurring producer behind the `ScheduleOccurrenceRecord` trail
- [Changes](https://osysharp.com/reference/entity/changes/) — the lighter, ungated "who changed this row" read, for when the full trail is more than you need
- [The AuditLog kit — the app's audit log, as a page of its settings](https://osysharp.com/reference/ui/audit-log-kit/) — a default audit log page, mounted with one `use` line
- [Where / Single / Count](https://osysharp.com/reference/query/where/) / [OrderBy / ThenBy](https://osysharp.com/reference/query/ordering/) / [ToList](https://osysharp.com/reference/query/tolist/) — the LINQ vocabulary a trail is queried with, same as any entity
