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.
Reading the record change feed
Section titled “Reading the record change feed”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:
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.
Change source
Section titled “Change source”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.SourceType | Meaning |
|---|---|
User | A 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. |
Function | A function run changed the record: a Command, an Action, a Job (including one enqueued through the API) or an HTTP endpoint |
Trigger | A trigger function that another record change started |
Schedule | A 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.
Record merge transfers
Section titled “Record merge transfers”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.
Lifecycle reasons
Section titled “Lifecycle reasons”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:
ReasonState | Meaning |
|---|---|
0 Provided | Lifecycle.Reason holds the stored reason |
1 NotProvided | The operation path stores no reason: demo-data cleanup, or an operation recorded without one |
2 Restricted | The 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.
Configuring audit per record type
Section titled “Configuring audit per record type”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:
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.
Audit from C# project code
Section titled “Audit from C# project code”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
IEntityAutomationCommandServicein 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 requireReason(1–1000 characters after trimming) and fail withmoltaro.instances.reason.requiredormoltaro.instances.reason.tooLongbefore any write. - Composite Entity mutations write normal field history as the current
user.
IEntityCompositeMutationServicesaves 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 asActorand 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.
MoltaroDbContextexposesAddBusinessEventAsync, 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
changesendpoint as aBusinessEventpayload 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.
IDynamicEntityAuditTrailImportServiceaccepts 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
changesendpoint above. - Boards and Entitlement application automation has fixed attribution.
IBoardAutomationCommandServiceandIEntitlementAutomationCommandServicepersist asmoltaro-system-automation. Their origin metadata retains the original user,FunctionRunId, function identity/version, andFunctionContext.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.
Where to go next
Section titled “Where to go next”- 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.