Osy#betaRun it on Osyrin: osyrin.com

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

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

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.

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.

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

FieldTypeMeaning
EntityTypestringthe changed entity's own name
EntityIdGuidthe changed row's id
ActionstringCreate / Update / Delete
UserIdGuidwho made the change
UserNamestring?their display name, snapshotted at the time of the change
ActingAgentstring?the agent name, when the change was agent-driven
TimestampDateTime?when the change was committed
ChangesJson?property deltas — { propertyName: { old: value, new: value } }, redacted per above
FunctionNamestring?the function that caused the change (null for a direct API mutation)
CorrelationIdstring?the request's correlation id, for end-to-end tracing
WorkflowInstanceIdGuid?set when the change was workflow-driven
Sourcestring?the origin channel — MCP, RestAPI, Function, Designer, Agent
ClientInfostring?the calling MCP client's self-reported name/version, when the source was MCP
OnBehalfOfUserIdGuid?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:

FieldTypeMeaning
EntityTypestringthe entity that was read
EntityIdGuidthe row that was read
UserIdGuidwho read it
UserNamestring?their display name, snapshotted at the time of the read
TimestampDateTime?when the read happened
AccessTypestringhow it was read — Query, GetById, API
ClassifiedPropertiesJsonthe names of the classified properties that were visible in the response
CorrelationIdGuidthe request's correlation id, for end-to-end tracing

WorkflowAuditRecord — the widened lifecycle timeline for a workflow run:

FieldTypeMeaning
InstanceWorkflowRunthe run this event belongs to
EventKindintwhat happened — Transitioned/Claimed/Released/Assigned/Cancelled/Reminded/Breached/Held/Resumed/Retargeted/Refused/Deposited/Orphaned/Retired/Faulted, coded 0–14 in that order
FromState / ToStateGuidthe states either side of the transition — valid only within the version this event was written under; read the names below instead
FromStateName / ToStateNamestring?the state names, recorded at write time, so the timeline survives a run migrating to a newer version
SlotSlotthe slot this event concerns
ActorGuid?who acted, when there was an acting principal
Reasonstring?a structured reason code or short reason
Messagestring?a free-text message
AtDateTimewhen the event happened
OldBudgetTicks / NewBudgetTickslong?the run's budget before/after, on a re-target event
ConsumedTickslong?ticks consumed at the moment of this event
PayloadJson?structured event payload, for the event kinds that carry one
PreviousAssigneeGuid?who held the slot immediately before, on a claim/assignment that took it from someone else
AssignmentCountint?the slot's assignment count AFTER this event, so churn is countable straight off the trail

AuthAuditRecord — one row per sign-in event:

FieldTypeMeaning
EventstringLoginSucceeded / LoginFailed / SignupSucceeded / Logout
OutcomestringSucceeded or Failed
FailureReasonstring?why a failed attempt failed (null on success)
UserIdGuid?the authenticated/created user — null when the attempt is for an unknown user
AttemptedLoginstring?the username/email the caller ATTEMPTED — recorded even for a failed unknown-user attempt
AuthMethodstringhow the caller authenticated — password, oauth, apikey
Providerstring?the OAuth identity provider, when AuthMethod was oauth
TimestampDateTime?when the event occurred
ClientInfostring?client identity — IP + user-agent, best-effort
CorrelationIdstring?the request's correlation id, for end-to-end tracing

ScheduleOccurrenceRecord — one row per occurrence the platform considered (see above):

FieldTypeMeaning
ScheduleSchedulethe schedule this occurrence belongs to
DueAtDateTimethe instant this occurrence was due
AtDateTimewhen the sweep considered it — on a catch-up this can be long after DueAt
OutcomeScheduleOccurrenceOutcomeProduced / Skipped / Retired / UnknownTarget
ProducedRowGuid?the row this occurrence created, on Produced
CoalescedCountinthow many FURTHER occurrences this one absorbed by re-arming past them — 0 on an ordinary occurrence
Notestring?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#

Related

principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) 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 you mark `[RoleGrant]` — one that holds a reference to your `[Principal]` and a…

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…

Where / Single / Count

Query an entity by writing a predicate over it. The query runs in the database — not a filter over rows you already…

OrderBy / ThenBy

Sort a query by one key or several. `OrderBy`/`OrderByDescending` start the sort, `ThenBy`/`ThenByDescending` add…

ToList

Runs the query and materialises the rows as a `List<Entity>`. Until you call it, a query is a description of what you…