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 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 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.
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.
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
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.
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. ForLlmCallRecordthis is the metrics/cost horizon, and should be ≥ContentRetention.- Omit
ContentRetentionand the body lives as long as the record; omitRetentionand the metrics are kept forever (bodies still expire onContentRetentionif 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 = falseturns 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 withusing 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 theapp { }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 referenceEntityAuditRecord/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#
- principal predicates (IsAuthenticated / IsAnonymous) 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 Osyrin.Observability;(the dependency) andusing Osyrin.Observability;(the import) - Schedule (recurring work) — the recurring producer behind the
ScheduleOccurrenceRecordtrail