Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Config

audit read access (app.Audit)

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

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

stable4 examples compiled by CIconfigauditsecurity

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

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

Signature#

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#

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.
    • Retention = <TimeSpan> — how long rows are kept before the platform purges them. See below.

Turning a trail off: Enabled#

Every trail is on by default. Enabled = false on a surface turns that one off — the platform stops recording it:

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#

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#

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:

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

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:

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#

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): an app may decline to record sign-ins with AuthAuditRecord = new AuditSurface { Enabled = false }.

LLM-call trail: LlmCallRecord#

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:

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 Osyrin.Llm.Observability; (its own capability), the same way the other trails open with using Osyrin.Observability; — see below.

Schedule-occurrence log: ScheduleOccurrenceRecord#

A Schedule (recurring work) 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:

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

The capability: use Osyrin.Observability;#

The audit trail is a capability, Osyrin.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 Osyrin.Observability gates one thing: whether your own code may name the audit records to query them.

  • use Osyrin.Observability; in the app { } manifest declares the dependency.
  • using Osyrin.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 for how use and using differ.

Examples#

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

[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:

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:

[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:

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

Related

principal predicates (IsAuthenticated / IsAnonymous) and open reads

Two built-in `when` predicates say who a request is: `IsAuthenticated` is a signed-in user, `IsAnonymous` is an…

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…

role grants (and the first admin)

A role is granted by an ordinary entity — any entity that has both a reference to your `[Principal]` and a property…

use

Declares a capability your app depends on, written inside the `app { }` manifest block. It provisions the capability…

Schedule (recurring work)

A schedule produces work on a cadence. Each occurrence creates a row of the entity its Template names — carrying…