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-optionsGET /api/workspace/entity/{entityIdOrKey}/record-merge/lookupPOST /api/workspace/entity/{entityIdOrKey}/record-merge/preparePOST /api/workspace/entity/{entityIdOrKey}/record-merge/executeGET /api/workspace/entity/{entityIdOrKey}/record-merge/operations/{operationId}
Authorization
Section titled “Authorization”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.
Prepare, decide, execute
Section titled “Prepare, decide, execute”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.
Preview the selected result
Section titled “Preview the selected result”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) orRequired(2). Requirements follow the field’sRequiredWhenandBusinessRequiredWhenbehavior for an update of the result, with a cleared field read as empty.Unverifiedmeans 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: whetherClearis allowed for this result;IsRequired: kept for earlier clients and now decided for the selected result, true forRequiredandUnverified.
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.
Readable consequences
Section titled “Readable consequences”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:
| Action | Meaning |
|---|---|
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.
Record merge rules
Section titled “Record merge rules”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.
| Code | HTTP | Meaning |
|---|---|---|
recordMerge.rule.failed | 400 | A rule is not TRUE; the message is the rule’s message. |
recordMerge.rule.configuration.invalid | 400 | A stored rule no longer compiles, for example after a field change. |
recordMerge.rule.restricted | 400 | A 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.
Transaction and errors
Section titled “Transaction and errors”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.
Reference fields
Section titled “Reference fields”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.
Open the original record after merging
Section titled “Open the original record after merging”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.
Read reference transfer history
Section titled “Read reference transfer history”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.
Association set-union
Section titled “Association set-union”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.
Related-data blockers
Section titled “Related-data blockers”| Code | Meaning |
|---|---|
recordMerge.reference.associationPayloadConflict | Overlapping Association edges have different non-endpoint business data. |
recordMerge.reference.associationArchivedConflict | An archived Association edge occupies an endpoint pair required by the merge. |
recordMerge.reference.duplicate | A 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.
Current limits
Section titled “Current limits”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.