# 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. The platform keeps six trails on by default (entity changes, classified reads, workflow transitions, sign-ins, LLM calls, schedule occurrences); 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).

<!-- 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>`. 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 six, each a named
read-surface: **`EntityAuditRecord`** (entity create/update/delete), **`AccessAuditRecord`** (reads of classified data),
**`WorkflowAuditRecord`** (workflow transitions), **`AuthAuditRecord`** (sign-in events), **`LlmCallRecord`** (LLM
calls — model, tokens, cost, and prompt/response), and **`ScheduleOccurrenceRecord`** (what a schedule did, or did not
do, each time it came due). `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 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).

### 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.

The **same one `Redact` policy also governs the LLM-call trail**. A classified value your app is allowed to read may
legitimately go into a prompt sent to the model — but if that value's property is covered by `Redact`, it must not be
persisted in the `LlmCallRecord` body. There is nothing extra to declare: the property's `[Classification]` (or its name
in `Redact`) is the whole instruction. The model still receives the value; the retained log does not. Precise
value-level redaction lands as the platform gains typed visibility into what an agent feeds a model; until then a call
known to carry such a value is logged **metrics-only** (tokens/cost/timing kept, prompt/response body dropped) — never a
partial leak.

### 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.

#### Two horizons for the LLM trail: `ContentRetention`     {#llm-retention}
The `LlmCallRecord` trail is special: each row holds a fat **body** (the prompt, response, and tool definitions) *and*
billing-grade **metrics** (tokens, cost, provider, model). Those want opposite lifetimes — the body is sensitive and
should expire quickly, the cost record is a financial fact you keep for reporting. So the LLM surface takes **two**
windows:

```osy syntax
app.Audit = new AuditConfig {
  LlmCallRecord = new AuditSurface {
    ContentRetention = TimeSpan.FromDays(30),    // the BODY (prompt/response) is cleared after 30 days
    Retention        = TimeSpan.FromDays(365),   // the whole record (incl. cost) is deleted after a year
  }
};
```

- **`ContentRetention`** — how long the prompt/response **body** is kept. Past it, the sweep **nulls the body columns**
  but keeps the row and all its metrics/cost. This member is **LLM-only** (no other surface has a body-vs-metrics split;
  declaring it elsewhere is a compile error).
- **`Retention`** — how long the **record** is kept, exactly as for the other surfaces; past it the whole row is
  deleted. For `LlmCallRecord` this is the metrics/cost horizon, and should be ≥ `ContentRetention`.
- Omit `ContentRetention` and the body lives as long as the record; omit `Retention` and the metrics are kept forever
  (bodies still expire on `ContentRetention` if set).

### System-access (sign-in) trail: `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 }`.

### LLM-call trail: `LlmCallRecord`     {#llm-trail}
Every call your app makes to a language model is recorded on the `LlmCallRecord` surface — the model and provider, the
token counts (input / output / cache), the computed **cost**, the timing, and (when logging is on) the prompt and
response. It is the per-call ledger behind "what did this app spend on which model." Like every trail it is
**host-written and read-only** to your app, gated by `app.Audit.LlmCallRecord.Read`, and switched by `Enabled`:

```osy syntax
app.Audit = new AuditConfig {
  LlmCallRecord = new AuditSurface { Read = user => user.IsAuditor, Enabled = true },
};
```

- **`Enabled = false` turns the whole per-call trail off** — no call rows are written. It does **not** affect the LLM
  budget: your daily token/cost limits are still enforced and rolled up (spend control is independent of the audit log).
- **The record is queryable** with `using Osysharp.Llm.Observability;` (its own capability), the same way the other
  trails open with `using Osysharp.Observability;` — see [below](#capability).

### Schedule-occurrence log: `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),
  }
};
```

### The capability: `use Osysharp.Observability;`     {#capability}
The audit trail is a **capability**, `Osysharp.Observability`. Its schema is always present — the platform records
`EntityAuditRecord` (entity create/update/delete), `AccessAuditRecord` (reads of classified data), and
`AuthAuditRecord` (sign-in events) whether or not your app opts in. The `use`/`using` for `Osysharp.Observability` gates
one thing: whether your own code may **name** the audit records to query them.

- **`use Osysharp.Observability;`** in the `app { }` manifest declares the dependency.
- **`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` is the exception that needs no `using` at all — a schedule is core rather than a
capability, so its occurrence log is in scope wherever you write a query.

`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.

## 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
[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
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
[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
[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) 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
