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.
What it guarantees and what it does not
Section titled “What it guarantees and what it does not”- 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
OperationKeyidentifies 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#
IEntityAutomationCommandServicecalls 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.
Request
Section titled “Request”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" } } } ]}| Member | Rule |
|---|---|
OperationKey | Required, non-empty UUID. Generate one per logical change; reuse it only for an exact retry of the same body. |
Steps | Required, 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. |
StepId | Required, 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. |
Create | EntityDefinitionKey, ExpectedDefinitionRowVersion (the record type’s current RowVersion), Values, ResponsibilityCandidates ([] when none are proposed) and optional StepReferences, as in the single-record create. |
Update | EntityDefinitionKey, 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.
Local references
Section titled “Local references”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.
Order of steps
Section titled “Order of steps”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.
Answer
Section titled “Answer”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:
Kind | When | Content |
|---|---|---|
RecordIdentity | The caller can read the record after the save | Record type id, record id, and the RowVersion the whole operation committed; use it for the next write |
RecordReceipt | An Update step whose record the caller cannot read after the save, for example because the step moved it out of the caller’s view | Record 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.
Errors
Section titled “Errors”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:
| Answer | Meaning |
|---|---|
200 with IsIdempotentReplay: false | This request saved every step. |
200 with IsIdempotentReplay: true, 403 moltaro.instances.replay.resultNotViewable, 409 moltaro.security.idempotency.replayInvalid | An earlier identical request saved; this one wrote nothing. |
503 moltaro.instances.composite.outcomeUnknown | It is not known whether this request saved. Repeat the identical request to find out. |
| Any other error with the envelope | This 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:
| Code | HTTP | What to do |
|---|---|---|
moltaro.instances.composite.stepLimitExceeded | 400 | Send at most Metadata.Maximum steps, the host’s limit; keep the steps of one change in one request. |
moltaro.instances.composite.requestTooLarge | 413 | Reduce the values, for example long texts, below Metadata.Maximum bytes, the host’s limit (1,048,576 by default). |
moltaro.instances.composite.stepReferenceInvalid | 400 | Point 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.duplicateTarget | 400 | Put all changes of one record into one step. |
moltaro.instances.composite.inputNotSupported | 400 | Leave File, InverseReference values and the Markdown draft headers out; save them with the single-record requests. |
moltaro.instances.composite.fieldNotWritable | 400 | Leave the read-only or calculated field out of the step. |
moltaro.instances.composite.budgetExceeded | 503 | Nothing was saved. Repeat the identical request after a delay, a few times at most. |
moltaro.instances.composite.outcomeUnknown | 503 | Repeat 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: …”.
Retries and replay
Section titled “Retries and replay”- 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: trueand writes nothing: no history, audit entries, events or trigger runs. - The replay checks read access again: a record the caller can read answers
RecordIdentitywith the committedRowVersion, an updated record the caller can no longer read answersRecordReceipt, and when a created record is no longer readable the whole answer is403moltaro.instances.replay.resultNotViewable. The records exist; never create them again with a new key. - The same key with a different body answers
409moltaro.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,budgetExceededandoutcomeUnknown, with a delay and a few times at most. See Reliable API automation.
Access
Section titled “Access”Each step is authorized for the caller with the ordinary Entity Security of its record type:
- An Update needs a visible record (
View, plusReadArchivefor an archived one), otherwise404moltaro.instances.notFound; thenUpdateandField:Writefor every sent value, including unchanged values, Table cells and fields filled by a local reference, otherwise403moltaro.instances.access.denied. An archived record answers409moltaro.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
CreateandField:CreateWritefor 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.
Execution budget
Section titled “Execution budget”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.
History, audit and triggers
Section titled “History, audit and triggers”- 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
OperationKeyandStepId, 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.
C# in the Net Operation Project
Section titled “C# in the Net Operation Project”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 answermoltaro.automation.executionPhase.unsupported, as for every application automation service. A call while the function’s database context has an open transaction answersmoltaro.automation.entity.ambientTransactionUnsupported: the composite commits in its own transaction and would not join yours. - Result.
SecurityMutationCommandResult<EntityCompositeMutationResult>;ThrowIfFailed()keeps the code,FieldandMetadata.Fieldpaths leave out theCreate/Updatesegment, for exampleSteps[1].RowVersionorSteps[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.
EntityCompositeMutationLimitsholds 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.