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).
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 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). app.Audit configures them:
AuditConfig— the top-level value assigned toapp.Audit. It holds one entry per surface you want to configure, plus the app-wideRedactpolicy.AuditSurface— a surface's config, carrying two optional members:Read = user => <predicate>— who may read the trail. The predicate binds the[Principal]asuserand 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'swheninlines them, and its names resolve in the file it was written in. It lowers to a deny-by-default read policy plus anallow read when <predicate>rule, enforced by the same security runtime as every other entity — no separate audit-permission path. A surface you never give aReadstays 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.
How does an Auditor of one tenant not see another tenant's trail?#
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 —
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 { } already draws
between a where (per row) and a when (per principal):
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
Organizationis 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'sScopestays null. - A null
Scopeis 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 eachDeleterecord carries its tenant. - Only
EntityAuditRecordis stamped today. The other five surfaces keep their existing principal-onlyRead;Scopeis additive; a surface you never mention behaves exactly as it did before this section existed.
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. DisablingEntityAuditRecordturns off the whole entity-change trail; for finer control, exclude a single entity with the[Audit(None)]attribute on its declaration. ReadandEnabledare independent.Readgoverns who may see an existing trail;Enabledgoverns 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
AuthAuditRecordoff 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.
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
Retentionis 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#
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 }.
Why didn't anything run last night? 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),
}
};Is my app audited at all? use Osysharp.Observability; decides#
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 theapp { }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 referenceEntityAuditRecord/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 for how use and using differ.
How do you read a trail?#
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,
OrderBy / ThenBy, Take/Skip, Select, Count, Any, 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):
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. 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.
What fields does each trail expose?#
Each surface below is the friendly name your code queries — see use for the using that brings a name
into scope. Every one of them, ScheduleOccurrenceRecord included, needs its capability used 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 |
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):
| 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#
An app whose audit trail is readable only by staff flagged as auditors. The [Principal] declares the IsAuditor
field the predicate reads:
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:
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:
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:
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#
- principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads — the
user => …predicate form, and the[Principal]it binds - security { } — the deny-by-default read policy +
allow read whenrule this lowers to - role grants (and the first admin) — granting a principal the role a predicate can test
- use —
use Osysharp.Observability;(the dependency) andusing Osysharp.Observability;(the import) - Schedule (recurring work) — the recurring producer behind the
ScheduleOccurrenceRecordtrail - 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 — a default audit log page, mounted with one
useline - Where / Single / Count / OrderBy / ThenBy / ToList — the LINQ vocabulary a trail is queried with, same as any entity