Skip to content

Composite Entity mutations

All /api/workspace/... routes below resolve against WORKSPACE_API_BASE_URL, the installation API origin supplied by the administrator. They do not resolve against moltaro.com.

A composite Entity mutation saves several standalone records as one change: every step commits, or none does. Use it when records belong to one logical fact, such as an assistance record and the legal detail that references it, and a failed second write must not leave the first one saved. (It is unrelated to the composite publication of Object collaboration.)

The operation is available when the installation’s Runtime OpenAPI lists operation entity-composite-mutations-execute and its installed error catalog lists surface EntityCompositeRest. Check the installation you call; an older build does not have the route.

  • One transaction. One request carries from one step up to the host’s step limit: 100 unless the host is configured otherwise, and at least 16 on every host. Each step creates or updates one standalone record (a record type that is not a Part of another record); steps can use the same or different record types. All steps commit together or the whole request is rolled back, including record history, audit entries and events.
  • The caller’s own access. Every step is checked with the caller’s ordinary Entity Security, as the single-record request would be. The operation never runs as the system automation account.
  • One key for the whole set. One OperationKey identifies the change. An exact retry of a committed request returns the stored result without writing again.
  • Request-blocking. The answer arrives after the commit; nothing is queued.
  • Opt-in. Only this operation is atomic. The single-record endpoints, Form saves in the web app, imports, bulk updates and C# IEntityAutomationCommandService calls keep their behavior, and sending them one after another is still not atomic.

A step cannot archive, restore, delete, merge, import or bulk-update records, write comments, attachments, tags or responsibility changes, or change Boards or Tasks items. File and InverseReference values and the Markdown draft headers are not accepted; save them with the single-record requests. Update steps have no responsibility input.

POST /api/workspace/entity-composite-mutations with a JSON body. This request creates an assistance record and a legal detail whose Assistance Reference field points to it. Replace the illustrative keys, fields and versions with values discovered from the installation:

{
"OperationKey": "6f8a6c1e-3a0e-4c7e-9b1f-5f2d9e4b7a10",
"Steps": [
{
"StepId": "assistance",
"Create": {
"EntityDefinitionKey": "BeneficiaryAssistance",
"ExpectedDefinitionRowVersion": "0b6f7c1c-8f7e-4f51-9f5e-2c7b3d0a9e11",
"Values": { "AssistanceDate": "2026-09-25", "Kind": "legal" },
"ResponsibilityCandidates": []
}
},
{
"StepId": "legal",
"Create": {
"EntityDefinitionKey": "LegalAssistanceDetail",
"ExpectedDefinitionRowVersion": "5a1d2e3f-0c4b-4a6d-8e9f-1b2c3d4e5f60",
"Values": { "CaseSummary": "Tenancy dispute" },
"ResponsibilityCandidates": [],
"StepReferences": { "Assistance": "assistance" }
}
}
]
}

An Update step names the record and the RowVersion you last read, and sends only the fields to change. Create and Update steps can be mixed in one Steps array:

{
"OperationKey": "c2d7f0a4-9b61-4e0e-a0c3-4f8f0f1d2b77",
"Steps": [
{
"StepId": "assistance",
"Update": {
"EntityDefinitionKey": "BeneficiaryAssistance",
"EntityInstanceId": "8c0e5d1f2a3b4c5d6e7f8091a2b3c4d5",
"RowVersion": "1e2d3c4b-5a69-4788-9a0b-c1d2e3f40516",
"Patch": { "Kind": "legal" }
}
},
{
"StepId": "legal",
"Update": {
"EntityDefinitionKey": "LegalAssistanceDetail",
"EntityInstanceId": "4a5b6c7d8e9f40a1b2c3d4e5f6a7b8c9",
"RowVersion": "9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a",
"Patch": { "CaseSummary": "Settled" }
}
}
]
}
MemberRule
OperationKeyRequired, non-empty UUID. Generate one per logical change; reuse it only for an exact retry of the same body.
StepsRequired, from one step up to the host’s step limit (100 by default), executed in this order. A request over the limit is refused with moltaro.instances.composite.stepLimitExceeded, whose Metadata.Maximum is the host’s limit; an exact retry of a request that already committed still gets its stored result after the host lowered its limit.
StepIdRequired, 1–64 characters: a Latin letter or digit, then letters, digits, ., _ or -. Unique within the request, ignoring letter case. Results and errors repeat it as sent.
CreateEntityDefinitionKey, ExpectedDefinitionRowVersion (the record type’s current RowVersion), Values, ResponsibilityCandidates ([] when none are proposed) and optional StepReferences, as in the single-record create.
UpdateEntityDefinitionKey, EntityInstanceId, RowVersion (the record’s current RowVersion), Patch and optional StepReferences, as in the single-record update.

Each step has exactly one of Create and Update. A step with both, neither or a null one, and an unknown member anywhere in the body, is refused with moltaro.request.binding.invalid; unknown members are never ignored. The body may be at most 1,048,576 UTF-8 bytes unless the host allows more; a larger one is refused with HTTP 413 moltaro.instances.composite.requestTooLarge before it is read.

Values and Patch hold field values in the shapes of the single-record requests; see Field value shapes when writing. Send only the fields you intend to write. The composite mutation never drops input: a read-only or calculated field is refused with moltaro.instances.composite.fieldNotWritable, where the single-record request would skip it with a warning. An Update step otherwise saves exactly like the single-record update: a sent value equal to the stored one is still written, creates a new record version and runs the update logic.

StepReferences fills a Reference field of the step’s record with the record that an earlier Create step creates. In the first example, { "Assistance": "assistance" } puts the new assistance record into field Assistance of the legal detail. The key is a primary-table Reference field of the step’s record type; the value is the StepId of an earlier Create step that creates a record of the field’s target type. The same field must not also be in Values or Patch. For a record that already exists, send its id in Values or Patch instead. A broken rule answers moltaro.instances.composite.stepReferenceInvalid with the reason in Metadata.Reason.

Steps run in request order, and each step sees what earlier steps wrote, as if the same single-record requests ran one after another inside one transaction: unique values, line numbers, validation rules, Business Invariants and Reference Eligibility all apply to that state. Two steps may not address the same existing record (moltaro.instances.composite.duplicateTarget): put all changes of one record into one step, and put the values of a new record into its Create step.

Maintenance that a step triggers, such as recalculating an aggregate parent or renumbering table lines, can change a record that a later Update step addresses. That step then fails with moltaro.security.concurrency.staleRowVersion and nothing is saved; the server never replaces the version you sent. Put Update steps of parent records before the steps that change their children.

A success is 200 OK, for the first execution and for every replay:

{
"Data": {
"Kind": "EntityCompositeMutation",
"OperationKey": "c2d7f0a4-9b61-4e0e-a0c3-4f8f0f1d2b77",
"CompletedAt": "2026-09-25T10:15:00.123456+00:00",
"AuditCorrelationId": "7d3f9c2e41b84f0a9e6d5c4b3a291807",
"IsIdempotentReplay": false,
"Steps": [
{
"StepId": "assistance",
"Result": {
"Kind": "RecordIdentity",
"EntityDefinitionId": "ebh1t6bD0WL4",
"EntityInstanceId": "8c0e5d1f2a3b4c5d6e7f8091a2b3c4d5",
"RowVersion": "3a51c7e2-9d84-4b6f-a0e3-5c2d1b4f6e78"
}
},
{
"StepId": "legal",
"Result": {
"Kind": "RecordReceipt",
"EntityDefinitionId": "3FyZVNonbPjn",
"EntityInstanceId": "4a5b6c7d8e9f40a1b2c3d4e5f6a7b8c9"
}
}
]
},
"Errors": [],
"Warnings": [],
"Success": true
}

Steps has one result per step, in request order, told apart by Kind:

KindWhenContent
RecordIdentityThe caller can read the record after the saveRecord type id, record id, and the RowVersion the whole operation committed; use it for the next write
RecordReceiptAn Update step whose record the caller cannot read after the save, for example because the step moved it out of the caller’s viewRecord type id and record id only. The change is committed; do not treat the receipt as a failure or invent a version

A Create step always answers RecordIdentity. If the caller could not read a new record in the final state, the whole request is rolled back with HTTP 403 moltaro.instances.access.denied, so no invisible record is ever created.

Results carry no field values. Read the records with the ordinary record read afterwards; a failing or denied read says nothing about the save, which the answer already settled. AuditCorrelationId is the request’s X-Correlation-ID, or a generated id; all steps share it. Warnings are returned with the first execution only.

An error answer has the same envelope with Data: null and Success: false. An error of a step names the step twice: in Field, as Steps[i] or a path inside it with i the zero-based step index, and in Metadata.StepId:

{
"Data": null,
"Errors": [
{
"Code": "moltaro.security.concurrency.staleRowVersion",
"Message": "The data changed after it was loaded. Reload it and try again.",
"Field": "Steps[1].Update.RowVersion",
"Metadata": { "StepId": "legal" }
}
],
"Warnings": [],
"Success": false
}

Errors of the whole request have no StepId: a lock conflict over several records, the execution budget, an unknown outcome, keyReuse and replayInvalid. Field values are named by their key, for example Steps[0].Create.Values.CaseSummary. When an answer has several errors, its HTTP status is the first of 404, 403, 413, 503 and 409 that one of the codes has, and 400 otherwise.

Every answer says one of four things about the save:

AnswerMeaning
200 with IsIdempotentReplay: falseThis request saved every step.
200 with IsIdempotentReplay: true, 403 moltaro.instances.replay.resultNotViewable, 409 moltaro.security.idempotency.replayInvalidAn earlier identical request saved; this one wrote nothing.
503 moltaro.instances.composite.outcomeUnknownIt is not known whether this request saved. Repeat the identical request to find out.
Any other error with the envelopeThis request saved nothing. 409 moltaro.security.idempotency.keyReuse adds that another request saved with this OperationKey.

An answer without the envelope, such as an HTTP 500, a timeout or a lost connection, shows nothing about the outcome: keep the key and the body and repeat the identical request.

The codes that only this operation answers:

CodeHTTPWhat to do
moltaro.instances.composite.stepLimitExceeded400Send at most Metadata.Maximum steps, the host’s limit; keep the steps of one change in one request.
moltaro.instances.composite.requestTooLarge413Reduce the values, for example long texts, below Metadata.Maximum bytes, the host’s limit (1,048,576 by default).
moltaro.instances.composite.stepReferenceInvalid400Point the reference at an earlier Create step of the field’s target type, or send the id as a value (Metadata.Reason).
moltaro.instances.composite.duplicateTarget400Put all changes of one record into one step.
moltaro.instances.composite.inputNotSupported400Leave File, InverseReference values and the Markdown draft headers out; save them with the single-record requests.
moltaro.instances.composite.fieldNotWritable400Leave the read-only or calculated field out of the step.
moltaro.instances.composite.budgetExceeded503Nothing was saved. Repeat the identical request after a delay, a few times at most.
moltaro.instances.composite.outcomeUnknown503Repeat the identical request, within seven days of the first attempt, until it answers; never send the steps with a new key before that.

Other codes keep their single-record meaning. The installed error catalog describes every code of this operation, with its Field pattern and safe reaction, under surface EntityCompositeRest and operation CompositeMutation; the error code reference renders the same entries under the headings “Composite: …”.

  • Store the OperationKey, the body and the time of the first attempt before you send the request. An exact retry resends the same key and the same body: the same steps in the same order, with the same versions and values.
  • Within seven days of the commit, the identical request answers the stored result with IsIdempotentReplay: true and writes nothing: no history, audit entries, events or trigger runs.
  • The replay checks read access again: a record the caller can read answers RecordIdentity with the committed RowVersion, an updated record the caller can no longer read answers RecordReceipt, and when a created record is no longer readable the whole answer is 403 moltaro.instances.replay.resultNotViewable. The records exist; never create them again with a new key.
  • The same key with a different body answers 409 moltaro.security.idempotency.keyReuse. Use a new key only for a genuinely new change.
  • The key belongs to the caller. The same user’s REST request and C# call share it, so an exact retry may switch between them; other users’ keys are independent.
  • After seven days the same key executes again and can create the records a second time. Do not repeat automatically when the time of the first attempt is unknown or older.
  • Repeat automatically only answers whose catalog reaction allows the same request, such as moltaro.instances.conflict, budgetExceeded and outcomeUnknown, with a delay and a few times at most. See Reliable API automation.

Each step is authorized for the caller with the ordinary Entity Security of its record type:

  • An Update needs a visible record (View, plus ReadArchive for an archived one), otherwise 404 moltaro.instances.notFound; then Update and Field:Write for every sent value, including unchanged values, Table cells and fields filled by a local reference, otherwise 403 moltaro.instances.access.denied. An archived record answers 409 moltaro.instances.patch.archived.
  • The rights of Update steps are evaluated once, on the state before the first step. An earlier step cannot grant a later Update step rights, for example by changing a reference or responsibility that the later record’s access depends on.
  • A Create needs Create and Field:CreateWrite for every sent value, checked on the new record as it will be saved, including what earlier steps wrote; the caller must be able to read the record in the final state.
  • A change of roles or security configuration applies before or after a request, never in the middle of it.

The server stops its own work when it cannot reach the commit within its execution budget, 15 seconds unless the host is configured otherwise, for example because other writes hold the records it needs or the steps take longer: the step limit does not promise that every request of that size fits the budget. The answer is 503 moltaro.instances.composite.budgetExceeded, and nothing is saved. The budget is not a response-time guarantee: authentication, the license check, reading the body, the commit itself and writing the answer are outside it. Checks that apply to every create, such as the license, apply to this request as well.

  • Each step writes the record history of the single-record save when the record type’s audit trail is enabled, with the caller as the author.
  • The workspace audit receives one entry per saved record, with the OperationKey and StepId, and all steps share one correlation.
  • Entity save functions (Validation and BeforeSaveMutation) run for every step inside the transaction. Their external effects, such as HTTP calls, are not rolled back and can repeat when the server retries the transaction after a conflict.
  • Over REST, Entity trigger functions of the saved records start after the commit, as for single-record saves. Writes of the C# service below do not start them, like other function-originated writes; history names the function run as the change source.
  • A rolled-back request or a replay adds nothing to history.

Command and HTTP endpoint functions can inject IEntityCompositeMutationService from Moltaro.Package.NET.Automation. It takes the same steps, with the same limits, rules and OperationKey journal as the REST operation, and the Net Operation Project registers it automatically.

  • Actor. The user who invoked the Command. For an HTTP endpoint, the bearer user (AuthenticatedUser) or the configured Service user (ConfiguredServiceUser). Never the system automation account.
  • Where it is refused. Jobs (including API-enqueued and queued ones), schedules, triggers, Actions and other runs without an admitted current user answer moltaro.automation.entity.currentUserInvocationRequired; nothing falls back to another user. Validation and BeforeSaveMutation functions answer moltaro.automation.executionPhase.unsupported, as for every application automation service. A call while the function’s database context has an open transaction answers moltaro.automation.entity.ambientTransactionUnsupported: the composite commits in its own transaction and would not join yours.
  • Result. SecurityMutationCommandResult<EntityCompositeMutationResult>; ThrowIfFailed() keeps the code, Field and Metadata. Field paths leave out the Create/Update segment, for example Steps[1].RowVersion or Steps[0].Values.CaseSummary. The installed error catalog describes the REST answers only; a C# result can report another code for the same condition.
  • Size. The host’s request-size limit (1,048,576 bytes by default) applies to the steps serialized as UTF-8 JSON, as an HTTP client would send them; the step limit is the host’s, as over REST. EntityCompositeMutationLimits holds the limits every host accepts (16 steps, 1,048,576 bytes), not this host’s own limits.

Pass the OperationKey in from the caller and resend it on every retry; do not generate it inside the function. Only the composite call is atomic and replay-safe: code before and after it, and the command’s own result, run again when the command is invoked again, and the repeated call gets the stored result back.

using System.Text.Json;
using Moltaro.Package.NET.Automation;
using Moltaro.Package.NET.Functions;
namespace Moltaro.Operational.Functions;
public sealed record SaveLegalAssistanceArgs(
Guid OperationKey,
Guid AssistanceDefinitionRowVersion,
Guid DetailDefinitionRowVersion,
string AssistanceKind,
string CaseSummary);
public sealed record SaveLegalAssistanceResult(
string AssistanceId,
string DetailId,
bool IsIdempotentReplay);
[MoltaroFunction(
"docs.examples.saveLegalAssistance",
Key = "docs.legalAssistance.save",
Name = "Save legal assistance",
Description = "Creates an assistance record and its legal detail in one transaction.")]
[MoltaroCommand(PermissionKey = "docs.commands.save-legal-assistance")]
public sealed class SaveLegalAssistanceCommand(IEntityCompositeMutationService entities)
: CommandFunction<SaveLegalAssistanceArgs, SaveLegalAssistanceResult>
{
public override async Task<CommandResult<SaveLegalAssistanceResult>> OnRunAsync(
SaveLegalAssistanceArgs args,
CancellationToken cancellationToken)
{
// Package code passes a new JsonSerializerOptions inline to every JsonSerializer call.
var mutation = new EntityCompositeMutation(
args.OperationKey,
[
new EntityCompositeCreateStep(
"assistance",
"BeneficiaryAssistance",
args.AssistanceDefinitionRowVersion,
new Dictionary<string, JsonElement>
{
["Kind"] = JsonSerializer.SerializeToElement(
args.AssistanceKind, new JsonSerializerOptions())
},
[]),
new EntityCompositeCreateStep(
"legal",
"LegalAssistanceDetail",
args.DetailDefinitionRowVersion,
new Dictionary<string, JsonElement>
{
["CaseSummary"] = JsonSerializer.SerializeToElement(
args.CaseSummary, new JsonSerializerOptions())
},
[],
StepReferences: new Dictionary<string, string> { ["Assistance"] = "assistance" })
]);
var result = await entities.ExecuteAsync(mutation, cancellationToken);
result.ThrowIfFailed(); // The run keeps the code, Field and Metadata.StepId.
// A Create step always answers a record identity.
var committed = result.Result!;
var assistance = (EntityCompositeRecordIdentity)committed.Steps[0].Result;
var detail = (EntityCompositeRecordIdentity)committed.Steps[1].Result;
return CommandResult<SaveLegalAssistanceResult>.Ok(new SaveLegalAssistanceResult(
assistance.EntityInstanceId,
detail.EntityInstanceId,
committed.IsIdempotentReplay));
}
}

The caller reads both record types’ current RowVersion and passes them with a new OperationKey. An Update step uses EntityCompositeUpdateStep with the record id, its RowVersion and a Patch, and can answer EntityCompositeRecordReceipt; match the outcome with a type pattern rather than a cast.

Artifacts that use this service are built with the current SDK: Package gateway contract 13 and Net Operation Project gateway contract 10. See Rebuild after an SDK contract change.