Skip to content

Record history and audit

Moltaro keeps an auditable history for record types that enable the audit trail: who changed what, when, and through which surface. This page covers the three developer touchpoints — reading history over the API, configuring audit per record type, and emitting business events from workspace C# code. For the user-facing view see Audit trail and History and audit.

All examples were executed against a real Moltaro installation; response bodies are real, trimmed for length.

GET /api/workspace/entity/{entityIdOrKey}/instances/{instanceId}/changes (operation) returns the change history of one record, newest first, paged with page/pageSize. The caller needs read access to the record and the Read changes history permission — history can expose values the current record state no longer shows:

Terminal window
curl -s "https://ops.example.com/api/workspace/entity/SupportTicket/instances/98520ecb203d4188abf10298a5a638e2/changes?page=1&pageSize=25" \
-H "Authorization: Bearer <token>"
{
"Data": {
"Items": [
{
"Id": "1e0b029f00ad49e7a3f193050414efd6",
"Operation": 1,
"ChangedAt": "2026-07-22T15:51:22.950314+00:00",
"Actor": { "Id": "3aba23d9-ef9b-4df8-a1cb-69a29781d5c4", "Name": "owner" },
"RecordContext": { "EntityDefinitionName": "SupportTicket", "DisplayNameSingular": "Support ticket" },
"FieldChanges": [
{ "Key": "ModifiedAt", "DisplayName": "Modified", "SystemField": 4,
"BeforeValue": null, "AfterValue": "2026-07-22T15:51:22.950314+00:00" },
{ "Key": "Priority", "DisplayName": "Priority", "SystemField": null,
"BeforeValue": "High", "AfterValue": "Medium", "FieldId": "l1eqb13B03NG" }
],
"TableChanges": [],
"AssignmentChanges": [],
"BusinessEvent": null
},
{
"Id": "f2992e9199d94ecb8c1bb6bc00533442",
"Operation": 0,
"ChangedAt": "2026-07-22T15:51:20.332602+00:00",
"Actor": { "Id": "3aba23d9-ef9b-4df8-a1cb-69a29781d5c4", "Name": "owner" },
"FieldChanges": [
{ "Key": "Number", "DisplayName": "Number", "SystemField": 0,
"BeforeValue": null, "AfterValue": "1" },
{ "Key": "Title", "DisplayName": "Title", "SystemField": null,
"BeforeValue": null, "AfterValue": "Printer in hall B is jammed" },
{ "Key": "Priority", "DisplayName": "Priority", "SystemField": null,
"BeforeValue": null, "AfterValue": "High" }
]
}
],
"TotalCount": 2,
"Page": 1,
"PageSize": 25
},
"Success": true
}

Each item carries the operation kind, the actor, the record context, and per-field BeforeValue/AfterValue pairs — for user-facing field kinds the values are display values (the Select field above reads "High", not the option key). System columns (number, created/modified stamps) appear as SystemField-tagged changes next to schema fields. Child-table row changes, assignment changes, and — when the entry was written by code rather than a field edit — a BusinessEvent payload (see below) complete the shape. An update that only resends stored values is still recorded as an update. Unless the save itself changes a value (a before-save change, a save function, a recalculated field, or a recomputed display name or subtitle), its entry lists only the system fields ModifiedAt (“Modified”) and LastActivityDateTime (“Last activity”), plus ModifiedByUserId (“Modified by”) when the acting user differs from the stored one or it is still empty after create. Deleted and archived records keep their history within the definition’s retention settings.

An entry can carry Source, which records where the change came from. The change feed returns permitted source metadata to callers who may read the record’s history; it never includes field values. A record merge’s correlation is a link to a related operation and is null when that operation is missing or the reader lacks source View + ReadArchive or survivor View, including ReadArchive for a survivor that is now archived or merged away. This redaction does not remove the permitted diff or the safe Record merge label.

Source.SourceTypeMeaning
UserA save in which before-save functions derived values (Contributors lists those runs), or a record merge (SourceLabel starts with Record merge). C# Merge keeps this existing source policy while Actor identifies the trusted system actor.
FunctionA function run changed the record: a Command, an Action, a Job (including one enqueued through the API) or an HTTP endpoint
TriggerA trigger function that another record change started
ScheduleA scheduled function run

For a function change, SourceLabel is the function key; FunctionRunId, FunctionId, FunctionVersionId and FunctionVersion identify the run and the version it executed, not the version published today. OriginalUserId and OriginalUserName name the user who started the chain: the caller of a Command, the user who enqueued an Action or a Job through the API, the user whose save started a trigger, or the bearer caller of an HTTP endpoint that runs as its configured service user. A scheduled run has none, and neither has an HTTP endpoint that runs as its calling user. A Job that another function running as the system actor enqueued, and a trigger started by a merge or responsibility change that automation made, currently name the system automation account. Actor is moltaro-system-automation for changes made through C# Entity application commands; the original user is attribution and grants no access. A composite Entity mutation from C# runs as the user the host admitted for the Command or HTTP endpoint, so that user is its Actor. Contributors lists the C# before-save function runs that changed the record in the same save; they never replace the run that issued the command. A before-save function that ran without changing the record is not listed. A C# before-save function is listed when the record’s field values or its own table rows differ after the function ran, whether the host saved the change for it or the function saved it itself; changes to other records, including a record of another Entity with the same ID, do not count.

For example, a Job that a coordinator enqueued through the API updates a record with ImportAsync, and a C# before-save function changes a value in the same save:

{
"Source": {
"SourceType": "Function",
"SourceLabel": "cases.importAssessments",
"FunctionRunId": "xOkQzA4Icv9L",
"FunctionId": "gM1IXWiUNLiD",
"FunctionVersionId": "xmlsYJdcHz0s",
"FunctionVersion": 1,
"OriginalUserId": "a5e930159c9f423e9a8c82a45654ba80",
"OriginalUserName": "coordinator",
"CorrelationId": "assessment-import-0924",
"Contributors": [
{
"FunctionKey": "cases.normalizeNote",
"FunctionRunId": "ChpOJZ8FbpHY",
"FunctionId": "RDpYiqCbZtYy",
"FunctionVersionId": "FD2Q91o6jddn",
"FunctionVersion": 1
}
]
}
}

When the coordinator later saves the record and the same before-save function changes it again, the entry has SourceType User, the coordinator as OriginalUserId and that new run as its only contributor; its function fields repeat that contributor.

Source: null means that the entry has no recorded origin: entries written before the source was recorded, saves by a person that no before-save function changed, and changes of responsibility. It does not mean a manual change, and an upgrade does not add a source to older entries.

Retrying an operation with its original OperationKey and unchanged input adds no entry: the record keeps one entry whose source is the run that made the change, even when the retry comes from a later run.

FunctionRunId is an identifier only. Run logs, arguments and results stay in function operations, which only workspace owners, administrators and configurators can open; an entry keeps its source after the run itself has been removed by run-history retention.

Changes that the C# Entity application commands Create, Update, Import, BulkUpdate, Archive, Restore and Delete make, and C# composite Entity mutations, do not start Entity trigger functions of the changed record. A C# merge or responsibility change still starts them, as do saves by a person, including saves that before-save functions changed, and composite Entity mutations sent over REST.

With the affected Entity’s audit trail enabled, a supported ordinary inbound Reference transfer writes one Update per owning record for that phase. Several scalar references and owned Table cells share that entry. It has the actual actor/time, structured old/new values and SourceLabel: "Record merge"; Lifecycle is null. A real response excerpt shows the scalar and Table shapes. The survivor and Association retain their existing separate history phases; source Archive carries explicit Record merge origin and its normal lifecycle reason.

Source correlation is the merge operation id, subject to current operation read permission. A safe unavailable Reference contains no hidden target identity or caption; native and Workspace UI history show the localized unavailable state. Field filtering also applies to Table BeforeRow/AfterRow. Revocation changes subsequent reads, including reads of old entries.

Required history writes share the merge transaction and fail the merge if they cannot be stored. Exact replay adds no entry. Ordinary transfer history does not run extra save functions or publish extra Entity Updated events. History off means no field-history entry; required lifecycle-reason evidence remains. An upgrade does not reconstruct missing old transfers or extend retention. Old entries without Source and missing/purged targets or operations stay readable through the same safe projection.

Archive, restore and permanent delete store the trimmed Reason in the same transaction as the operation. Lifecycle entries of the change feed carry Lifecycle with a numeric ReasonState:

ReasonStateMeaning
0 ProvidedLifecycle.Reason holds the stored reason
1 NotProvidedThe operation path stores no reason: demo-data cleanup, or an operation recorded without one
2 RestrictedThe record changed through a cascade and the change history of the record whose operation started it is not readable by the caller or no longer exists; Reason is null

Group archive, restore and delete in the table ask for one reason and store it with every selected row. A record merge requires a Reason and stores it with the archive of the merged record and of association rows the merge combined.

Lifecycle.Cascaded is true when the record changed through a cascade from another record’s operation; a visible reason is then that operation’s reason. Lifecycle is null on other operations and on lifecycle entries recorded by earlier releases. That reason was not saved and is not reconstructed.

A failed, rolled-back or exactly retried operation adds no reason. The change feed of an existing record shows archive and restore reasons when the record type’s audit trail is on. The reason is stored even when the audit trail is off and survives permanent deletion; in those cases only a workspace Owner or Admin can read it, in Administration > Audit (filter by record type or record ID) or through audit-events-search with objectType=MoltaroEntityInstance&objectId=<record ID>; the reason is the audit.data.reason data item. The lifecycle event names the record type and record ID and never includes former field values.

The audit log does not bypass record rights. For an existing record the caller cannot read, every event names the record by type and ID only. Events about that record also omit their details: reason, comment text, file and tag names. For a readable record, a stored record name is shown only when it matches the name the caller sees today from the fields they may read; comment text needs the record’s comment view right, file names need its attachment view right, and a lifecycle reason needs its change history right. Each file an event names is also judged by the file’s stored owner, even when the event does not name a record, as older file events are stored: an attachment needs the owning record’s attachment view right, and a File field file needs read access to the field that holds it (and to its table field, for a field inside a table field). When the stored file no longer shows whose content it is, an event that names a record shows the file name only to callers who may read all files of that record, its attachments and every File field; when neither the event nor the stored file determines the owner, the file name is hidden and the event keeps its identifiers. Such events return RecordDataHidden: true. After permanent deletion no record rights remain, so the deletion evidence and its reason stay readable to a workspace Owner or Admin. Lifecycle reasons are not deleted automatically; the record type’s audit retention and the seven-day retry window do not remove them.

Two module surfaces have their own history reads with the same philosophy: board items expose history and audit-trail, and Entitlement Operations keeps quantity truth in the append-only ledger.

Audit is a per-definition setting, and new definitions start with it on with unlimited retention. PUT /api/workspace/admin/entity-definitions/{entityDefinitionId}/audit-settings (operation) changes it:

Terminal window
curl -s -X PUT https://ops.example.com/api/workspace/admin/entity-definitions/pYZqCLZWb0K1/audit-settings \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{ "AuditTrailEnabled": true, "AuditTrailRetentionMode": 1,
"AuditTrailRetentionDays": null,
"RowVersion": "a244a235-1dc6-4e8b-81cf-2db6b972cd55" }'

AuditTrailRetentionMode is 0 disabled, 1 keep forever, 2 keep a rolling window of AuditTrailRetentionDays days. The definition detail exposes the current state as AuditTrailEnabled — the Integration quickstart discovery response shows it per record type, so an agent knows up front whether a record carries history.

Workspace C# logic (the Net Operation Project) has an explicit relationship with the audit trail:

  • Entity application commands from C# write normal field history. Inject IEntityAutomationCommandService in a supported function phase and use its create/update/import/bulk-update commands when you need the complete application flow. The definition must enable audit. Every entry records the function run that issued the command as its change source, and these writes do not start Entity trigger functions. With the audit trail off, no entry is written. Its archive, restore and delete commands require Reason (1–1000 characters after trimming) and fail with moltaro.instances.reason.required or moltaro.instances.reason.tooLong before any write.
  • Composite Entity mutations write normal field history as the current user. IEntityCompositeMutationService saves records in one transaction, up to the host’s step limit (100 by default), from a Command or HTTP endpoint. Each step’s entry names the invoking user as Actor and the function run as its change source; a rolled-back or replayed call writes no entry. See Composite Entity mutations.
  • Direct EF saves have automatic field audit off in the hosted generated context. This supported trusted path has different guarantees. The function host sets source metadata; it does not separately discover and audit every record changed through EF. A before-save contribution to the record being saved by the application is covered by that application’s final audit, not by a blanket audit of the function’s other writes.
  • Explicit business events explain custom operations. MoltaroDbContext exposes AddBusinessEventAsync, which appends a display-safe event to the record’s audit trail — it requires the definition to have the audit trail enabled:
await Context.AddBusinessEventAsync<SupportTicket>(
ticketId,
new MoltaroBusinessEvent(
Title: "Escalated to on-site service",
Message: "SLA breach predicted; field engineer dispatched.",
Values:
[
new MoltaroBusinessEventValue("engineer", "Engineer", "K. Malek"),
new MoltaroBusinessEventValue("eta", "ETA", "2026-07-23")
]),
cancellationToken);
  • Business events surface in the change feed. The entry arrives with the acting user and shows up in the changes endpoint as a BusinessEvent payload and in the WebApp record history — one narrative for humans and agents. They do not calculate field diffs. Keep narrative values display-safe; they are not automatically filtered as individual field changes. The event writes immediately: use the same explicit transaction as the direct EF save when they must commit together, and guard replay against duplicate events.
  • Historical audit import is a separate existing contract. IDynamicEntityAuditTrailImportService accepts trustworthy external history; it must not be used to invent old values for records with missing history. See the write-path comparison and checked import example before choosing between field audit, business events and historical import.
  • Reading audit from C# goes through the API, not the DbContext. The generated workspace contract does not expose audit rows as queryable DbSets; read history through the changes endpoint above.
  • Boards and Entitlement application automation has fixed attribution. IBoardAutomationCommandService and IEntitlementAutomationCommandService persist as moltaro-system-automation. Their origin metadata retains the original user, FunctionRunId, function identity/version, and FunctionContext.CorrelationId, so operators can join a function run to Boards history/resource events or the Entitlement audit/ledger. Automation request DTOs intentionally cannot replace that actor.
  • Access and governance — roles, entity access models, and the permissions that gate history reads.
  • The workspace activity feed — cross-record activity for dashboards and monitoring.
  • C# business logic — the full programming model around the code shown here.
  • Reliable API automation - the cross-surface table for automatic record audit, explicit business events, Boards history, Entitlement ledger truth, and project publication state.