Skip to content

Record merge API

Record Merge consolidates two entity instances of the same Entity Definition into one existing survivor. It is an explicit governed mutation; Record Matching can identify candidates, but it never authorizes or executes a merge.

Use the Runtime API from the target Moltaro installation, not moltaro.com. The five operations are published in the Runtime API reference:

  • GET /api/workspace/entity/{entityIdOrKey}/record-merge/launch-options
  • GET /api/workspace/entity/{entityIdOrKey}/record-merge/lookup
  • POST /api/workspace/entity/{entityIdOrKey}/record-merge/prepare
  • POST /api/workspace/entity/{entityIdOrKey}/record-merge/execute
  • GET /api/workspace/entity/{entityIdOrKey}/record-merge/operations/{operationId}

Launch and lookup results are filtered through the entity’s active Security Statements. The caller needs View and Merge on the source, and View plus Update on the survivor. Every survivor field changed by a decision also requires Field:Write.

Association rows coalesced as a related side effect use the authority of the already-authorized target merge. They do not require a separate Archive or Merge permission on the Association Entity Definition. Association lifecycle hooks, Reference Eligibility, business invariants, row-version checks, and audit history still apply.

Call prepare with exactly two instance ids and a launch surface. Preparation is read-only. It returns the current row versions, field and table comparisons, the selected result, the readable consequences and blockers that must be resolved before execution. FirstInstanceId is the survivor and SecondInstanceId the source.

Choose which record is the survivor, send explicit field and table decisions, and copy the row versions into execute. A changed source, survivor, or related row makes the execution fail with a conflict instead of applying an outdated plan.

OperationKey is a non-empty UUID. Reuse it only to retry the exact same request. An exact retry returns the original completed operation; reusing the key with a different request returns moltaro.security.idempotency.keyReuse.

Reason is required: 1–1000 characters after trimming leading and trailing whitespace. Moltaro stores the trimmed reason as the lifecycle reason of the source archive and of every Association row the merge archives. A missing, blank, or over-long reason returns HTTP 400 with moltaro.instances.reason.required or moltaro.instances.reason.tooLong and Field: Reason before anything changes; such a rejection does not use up the OperationKey. The reason is part of the exact request, so a retry must send the same reason.

Reason became required after merges were first published. A client that sends no reason now receives that HTTP 400 and must add one; the rejected request does not use up its OperationKey. A merge completed before the change cannot be replayed by key: read it with GET /api/workspace/entity/{entityIdOrKey}/record-merge/operations/{operationId} instead.

Send the current decisions to prepare to check the result they select. Both lists are optional and have the same shape as in execute; a client that sends none gets the initial result, which keeps every survivor value.

{
"FirstInstanceId": "survivor-id",
"SecondInstanceId": "source-id",
"Surface": 0,
"FieldDecisions": [
{ "FieldKey": "Format", "Decision": 1 },
{ "FieldKey": "Location", "Decision": 2 }
],
"TableDecisions": [
{ "FieldKey": "Lines", "Mode": 2, "SelectedSourceRowIds": ["row-id"] }
]
}

Decision is KeepSurvivor (0), TakeSource (1) or Clear (2). For the selected result, each field reports:

  • RequirementState: Unverified (0), NotRequired (1) or Required (2). Requirements follow the field’s RequiredWhen and BusinessRequiredWhen behavior for an update of the result, with a cleared field read as empty. Unverified means the server cannot decide it safely for you, for example because the condition reads a field you cannot read on the record it reads; treat it as required, not optional;
  • CanClear: whether Clear is allowed for this result;
  • IsRequired: kept for earlier clients and now decided for the selected result, true for Required and Unverified.

Blockers name the field in FieldKey when they belong to one, and the rule in RuleId when a merge rule refused. Field blockers are moltaro.instances.field.required, moltaro.instances.field.requiredNotWritable and recordMerge.field.notEditable, a change the result does not allow; execute refuses that change through the save as moltaro.instances.field.invalid. DeferredChecks lists the checks that only execute performs over the current state: current records and permissions, save processing, validation rules, business invariants, reference checks, C# save functions, and merge rules over the saved result. A draft without blockers does not mean these checks will pass.

Prepare never writes, never runs save functions and reserves nothing. Send it again after each change of decisions; send execute only with the decisions of the latest prepare you displayed.

Impact.Definitions summarizes the inbound links that execute will change, from the same plan execute uses. Each definition has its readable active and archived record counts and its links. A link names the referencing field (FieldKey, and TableFieldKey for a Table cell), its Kind (Reference 0, TableReference 1, AssociationEndpoint 2) and its Action:

ActionMeaning
MoveToSurvivor (0)The link points to the survivor after the merge.
ArchiveAsDuplicate (1)An equivalent Association row is archived by set-union.
KeepOnSource (2)An archived Association row stays on the archived source.
MoveOrArchiveAsDuplicate (3)The row moves, or is archived when the survivor already has an equal link; the preview does not say which, because it can depend on a link you cannot read.

Counts are records you can read: View, ReadArchive for archived records, and read access to the referencing field. A record is counted once per definition even with several links. RowCount counts Table rows for a TableReference link. Records, links and definitions you cannot read are omitted; there is no total of hidden records. ArchiveAsDuplicate is reported only when you can read the source row and the link that causes it, including both endpoints and every payload field the comparison uses; otherwise an active Association row is MoveOrArchiveAsDuplicate, whether or not a hidden equal link exists. MoveToSurvivor is reported for an active Association row only when you can read every compared value of every link involved. A Reference value, whether an endpoint, a payload field or a value in a field condition, counts as readable only with Field:Read of the field and View of the record it points to, plus ReadArchive when that record is archived, as in an ordinary read. Conflicts are blockers, not consequences. The summary is a snapshot: execute reads, locks and plans again.

A merge rule is a validation rule with Scope: 1 (RecordMerge) on the Entity Definition; see Record merge rules for the expression roots Source, Survivor and Result. Create it with the validation-rule endpoints of the Configuration API and validate the expression with Scope: 1. An update without Scope keeps the stored scope:

{
"Name": "SameBeneficiary",
"DisplayName": "Same beneficiary",
"Expression": "Source.Beneficiary = Survivor.Beneficiary",
"Message": "Only records of the same beneficiary can be merged.",
"Enabled": true,
"Scope": 1
}

Every enabled rule must be TRUE. Rules apply to prepare (as blockers), to execute without a previous prepare, and to C# IEntityAutomationCommandService.MergeAsync, which keeps its trusted system actor. execute checks the rules over the locked records and the selected result before writing, and again over the saved survivor after save functions; a refusal at either point rolls the whole merge back. A Result rule must hold for the selected values too: it can refuse before a save function would have set the value it reads. Rule changes after prepare apply. An exact replay returns the completed operation without checking rules again.

CodeHTTPMeaning
recordMerge.rule.failed400A rule is not TRUE; the message is the rule’s message.
recordMerge.rule.configuration.invalid400A stored rule no longer compiles, for example after a field change.
recordMerge.rule.restricted400A rule reads a field you cannot read; it is not evaluated and the answer is the same for any hidden values.

Each error carries EntityValidationRuleId, EntityValidationRuleName and EntityValidationRuleDisplayName metadata. Nothing was merged.

The merge executes in one PostgreSQL transaction. On success, Moltaro updates the survivor, remaps supported inbound references, archives the source, writes a merge marker and operation log, and keeps normal audit evidence. Any failed invariant, eligibility rule, concurrency check, or related-data write rolls the whole transaction back.

Choose TakeSource to copy a populated, readable Reference. Moltaro converts the resolved read value into the identifier required by its normal save pipeline. An empty source value remains empty. Reference cells in Table SourceOnly and Manual decisions follow the same rule. A restricted, unresolved or invalid reference cannot be copied and is never silently cleared. Current field-write permissions and Reference Eligibility are checked again when executing.

Send the merge decision rather than a raw replacement value. Ordinary Entity PATCH still expects a Reference identifier or null; it does not accept the Reference object returned by a read. Prepare does not reserve permissions or guarantee that a later execute will succeed.

The source remains an archived record marked as merged. If you have both View and ReadArchive, you can open its normal card, including through a direct link or after reloading, and read the history and resources your permissions allow. Comments, attachments and tags keep their own access checks and ownership.

Opening the source does not allow updating it, restoring it or merging it again. It also does not grant access to the survivor. If that record is hidden from you, the source does not reveal its identifier, merge-operation identifier or link. The card never redirects you to an unauthorized survivor. A projection link must pass the read checks for both the projection and its underlying record.

After a merge, read the ordinary history of each record whose inbound Reference changed with GET /api/workspace/entity/{entityIdOrKey}/instances/{instanceId}/changes against WORKSPACE_API_BASE_URL. With the audit trail enabled for that record type, the transfer is an Update with its old/new reference, actor, timestamp and SourceLabel: "Record merge". Several scalar references and owned Table cells on one record share one entry for that transfer phase. Table rows retain their IDs and order; the history belongs to their owning record.

This excerpt comes from a synthetic Assistance history response. System-field changes and additional reference/snapshot metadata are omitted for readability. Operation: 1 is Update and Table ChangeType: 1 is an updated existing row. The operation id is generated by the host; it is different from OperationKey.

{
"Operation": 1,
"ChangedAt": "2026-10-01T05:10:49.037752+00:00",
"Actor": { "Id": "838eee3f-4952-4199-afef-22923a5f2473", "Name": "owner" },
"Source": {
"SourceType": "User",
"SourceLabel": "Record merge",
"FunctionRunId": null,
"FunctionId": null,
"FunctionVersionId": null,
"FunctionVersion": null,
"OriginalUserId": "838eee3f-4952-4199-afef-22923a5f2473",
"CorrelationId": "coxUK2O82Goq",
"Contributors": null,
"OriginalUserName": "owner"
},
"FieldChanges": [{
"Key": "Case",
"DisplayName": "Case",
"BeforeValue": { "State": 0, "Id": "ae623df6d5e54a82b6eaa086b06c12ff", "Number": "2", "DisplayName": "Source" },
"AfterValue": { "State": 0, "Id": "87f2640be7c34f9185c567ba032568e3", "Number": "1", "DisplayName": "Survivor" }
}],
"TableChanges": [{
"FieldKey": "Lines",
"DisplayName": "Detail",
"RowChanges": [{
"ChangeType": 1,
"RowId": "323dedfe07bd47df8910d53c72c389ce",
"BeforeSortOrder": 0,
"AfterSortOrder": 0,
"FieldChanges": [{
"Key": "Case",
"DisplayName": "Case",
"BeforeValue": { "State": 0, "Id": "ae623df6d5e54a82b6eaa086b06c12ff", "Number": "2", "DisplayName": "Source" },
"AfterValue": { "State": 0, "Id": "87f2640be7c34f9185c567ba032568e3", "Number": "1", "DisplayName": "Survivor" }
}]
}]
}],
"Lifecycle": null
}

The complete Table row also carries BeforeRow and AfterRow, with unchanged ID/order and currently readable fields. Read those snapshots under the same permissions as the cell diff; they are not a raw storage export.

Survivor Update, ordinary transfers, source Archive and Association changes use the same host-generated merge operation id as Source.CorrelationId. Other phases on the same record can have separate entries without repeating the same diff. Source Archive carries its normal lifecycle reason; transfer Updates do not copy that reason or pretend to be lifecycle commands.

History requires current View and ReadChangesHistory, plus ReadArchive when needed. Field and referenced-record permissions filter the response, including Table snapshots. A hidden or deleted target has the normal unavailable reference state without its ID or presentation. Correlation is returned only when the reader can read the related merge operation: source View + ReadArchive and survivor View, with ReadArchive also required for a survivor that is now archived or merged away. Otherwise it is null, while the permitted diff and Record merge label remain readable. Rights are checked again on every read.

The ordinary transfer phase uses one timestamp taken after Association saves for its history and modified/last-activity fields. An Association with extra ordinary references therefore keeps the same phase order in its snapshots and history feed.

Required history writes commit in the merge transaction; their failure rolls it back. Exact replay adds no history, lifecycle evidence, function runs or events. Ordinary transfers still do not invoke extra save functions or Entity Updated events. C# MergeAsync keeps its trusted system actor and existing merge-source policy; its actor is not an inferred interactive initiator.

With history disabled, no field history is written and the setting stays off. The merge operation and required archive-reason evidence remain separate facts. Earlier absent transfers are not reconstructed. Missing or purged history, targets and operations do not extend retention or create replacement evidence. New audit rows do not automatically recalculate a customer’s derived read model. See Record history and audit.

Moltaro applies set-union only to a real UsageType = Association definition with valid endpoint binding metadata and a unique rule containing exactly the two endpoint fields.

For each final endpoint pair, Moltaro keeps an existing active survivor edge when one exists. Otherwise, it deterministically remaps one source edge through the normal Entity update lifecycle, including update hooks, calculated values, inverse-reference and audit history, projections, and resource events. Equivalent extra source edges are archived through the normal Entity lifecycle, so their original endpoints and audit history remain available. Self-associations submit both endpoint fields together.

Edges are equivalent only when all non-endpoint business fields are equal. Moltaro never guesses between different payloads. Archived source edges are historical and are not rewritten. An archived edge already occupying a required survivor pair also blocks the merge; it is not restored or deleted automatically.

Ordinary Data link-entities do not opt into this behavior. Their uniqueness collisions continue to fail closed.

CodeMeaning
recordMerge.reference.associationPayloadConflictOverlapping Association edges have different non-endpoint business data.
recordMerge.reference.associationArchivedConflictAn archived Association edge occupies an endpoint pair required by the merge.
recordMerge.reference.duplicateA non-Association reference remap would violate a uniqueness rule.

Messages name the affected Association or entity but do not reveal hidden field values or record identifiers.

HTTP statuses follow the Record merge section of the error code reference. An error answer with the response envelope means that this request merged nothing, except recordMerge.replay.resultNotViewable: an exact retry found the merge already completed, but you can no longer read the survivor. HTTP 401, or 403 without a body, refuses access and merges nothing. Any other answer without the envelope, or no answer, leaves the outcome unknown: read the records or repeat the identical request before sending a new one. moltaro.definitions.notFound answers 404 and moltaro.entityUi.fieldBehavior.configuration.invalid answers 409; earlier versions answered 400.

Record Merge handles one explicit pair per operation. It does not auto-merge matching results, merge in bulk, create a third replacement record, or provide split/undo. Keep the returned operation id as durable audit and support evidence.