| Internet-Draft | JMAP Conditional | September 2026 |
| Gondwana | Expires 19 March 2027 | [Page] |
The JMAP base protocol ([JMAP-CORE]) provides the Foo/set method for creating, updating, and destroying objects. It offers a single concurrency control, the "ifInState" argument, which guards an entire object type: if any object of that type has changed, the whole method is rejected.¶
This extension adds a finer, per-object conditional mechanism. A client may require that an individual update or destroy proceed only if the target object still matches a set of expected property values, expressed using the JMAP PatchObject already defined for updates. This provides optimistic concurrency control scoped to a single object — the equivalent of an HTTP "If-Match" precondition — for any JMAP data type.¶
This extension also defines an optional "atomic" argument that applies an entire Foo/set as a single unit: either every change it requests takes effect, or none does. Combined with the per-object precondition, this lets a client express a multi-object change that is safe only when applied together — such as an atomic rename that exchanges two names.¶
This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.¶
Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.¶
Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."¶
This Internet-Draft will expire on 19 March 2027.¶
Copyright (c) 2026 IETF Trust and the persons identified as the document authors. All rights reserved.¶
This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) in effect on the date of publication of this document. Please review these documents carefully, as they describe your rights and restrictions with respect to this document. Code Components extracted from this document must include Revised BSD License text as described in Section 4.e of the Trust Legal Provisions and are provided without warranty as described in the Revised BSD License.¶
The Foo/set method defined in [JMAP-CORE] is the mechanism by which clients create, update, and destroy objects. It provides one form of concurrency control: the optional "ifInState" argument, which aborts the entire method if the account's state string for that data type does not match the value the client supplies.¶
This guard is coarse. The state string changes whenever any object of that type changes, by any client. In a busy account — or one that receives server-initiated changes, such as the arrival of new mail — the state can change between a client's read and its write for reasons entirely unrelated to the object the client is modifying. Using "ifInState" to protect a single update therefore leads to frequent spurious rejections and retries.¶
Clients commonly need a narrower guarantee: "apply this change only if the specific object I read still holds the values I depend on". This is the same need met by the HTTP "If-Match" precondition ([HTTP-SEMANTICS]) and the entity-tag model of WebDAV ([WEBDAV]): a conditional write scoped to a single resource.¶
This document defines a generic, per-object conditional mechanism for Foo/set. It introduces no new properties and no new version tokens. Instead it reuses the PatchObject already defined by [JMAP-CORE] for updates: a client states its precondition as a PatchObject describing the values it expects the object to currently hold, and the server performs the change only if applying that patch would change nothing.¶
Because the mechanism is defined entirely in terms of an object's properties and the existing PatchObject semantics, it applies uniformly to every data type that implements Foo/set, with no per-type additions.¶
A second, related need is atomicity. [JMAP-CORE] processes the creations, updates, and destroys within a Foo/set independently: some may succeed while others fail, and the method gives no guarantee that a set of related changes takes effect together. The only all-or-nothing control Core offers is "ifInState", which — like the concurrency problem above — is scoped to the entire object type rather than to the changes the client actually requested. This document therefore also defines an optional "atomic" argument that commits a Foo/set as a single unit, evaluated against the state that results from applying all of its changes together (see Section 4).¶
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.¶
The terms "Id", "PatchObject", "SetError", and "Foo/set" are defined in [JMAP-CORE]. A "pointer" is a JSON Pointer as used within a PatchObject (see [JMAP-CORE], Section 5.3).¶
The presence of the "urn:ietf:params:jmap:conditional" property in the "capabilities" object of the JMAP Session resource indicates support for the conditional Foo/set behaviour defined in this document. The value of this property MUST be an empty object.¶
A client indicates that it wishes to use this extension by including the capability URI in the "using" array of a request. When a server that advertises this capability receives a request whose "using" array includes this URI, it MUST honour the "ifUnchangedBy" argument (Section 3) on every Foo/set method it implements. It MUST also honour the "atomic" argument (Section 4): either applying the method atomically, or, if it cannot, rejecting it with a "cannotApplyAtomically" error (Section 4). A server MUST NOT apply the changes of a method requested with "atomic" only in part.¶
The capability is generic: it does not depend on any particular data type, and its presence applies to all types for which the server implements Foo/set.¶
"urn:ietf:params:jmap:conditional": {}
¶
This document adds the following arguments to the standard Foo/set method ([JMAP-CORE], Section 5.3):¶
ifUnchangedBy: "Id[PatchObject]" (default: an empty object)¶
A map of object id to a PatchObject expressing a precondition on that object. For each entry, the precondition is satisfied if and only if applying the given PatchObject to the current server-side object would leave the object unchanged.¶
Equivalently: for every pointer in the PatchObject, the object's current value at that pointer MUST equal the value given in the PatchObject. A JSON "null" value matches a property that is not present (it asserts that removing the property would be a no-op). Comparison uses the same representation the server would return for that property from Foo/get.¶
atomic: "Boolean" (default: false)¶
If true, the Foo/set is applied as a single unit: the server MUST either commit every creation, update, and destroy the method requests, or make no change at all. See Section 4.¶
Preconditions are evaluated against the state of each object as it exists at the start of the method, before any create, update, or destroy in the same method is applied.¶
Each key in "ifUnchangedBy" is the id of an object of the same type as the method (the "Foo" of "Foo/set"). The object need not be one the method changes. A client MAY set a precondition on an object the method does not change, for example its parent. A precondition is not satisfied if its object does not exist, or is not visible to the client. The server then handles it as in Section 3.2. A creation id (an id prefixed with "#") MAY be a key. It references an object created by an earlier method call in the same request ([JMAP-CORE], Section 5.3). If the creation id resolves to an object of a different type, the server MUST reject the whole method with an "invalidArguments" error. This lets a client require that an object it created earlier in the request has not changed since then.¶
Unlike the PatchObject supplied in the "update" argument, which may only reference properties the client is permitted to set, the PatchObject in "ifUnchangedBy" MAY reference any property of the object, including server-set and otherwise read-only properties (for example a content identifier, a size, or a server-maintained change timestamp). This allows a client to condition a change on properties it cannot itself modify.¶
If a pointer in an "ifUnchangedBy" PatchObject is not a valid pointer for the object's type, or the PatchObject is otherwise malformed, the server MUST reject the whole method with an "invalidArguments" error.¶
The "ifUnchangedBy" and "ifInState" arguments are both method-level guards and compose. If both are supplied, the server checks "ifInState" first and then the "ifUnchangedBy" preconditions; if either does not hold, the method is rejected with a "stateMismatch" error and no change is made.¶
If every precondition in "ifUnchangedBy" is satisfied, the method proceeds exactly as it would without this extension.¶
If any precondition is not satisfied, the server MUST make no change — no creation, update, or destroy in the method takes effect — and MUST reject the whole method with a method-level "stateMismatch" error (Section 3.3). The state string for the type MUST be unchanged.¶
This is all-or-nothing at the method level, by design: a single Foo/set is the unit of conditional application. A client that wants changes to succeed or fail independently of one another places them in separate method calls within the same request, each with its own "ifUnchangedBy".¶
A failed "ifUnchangedBy" precondition is reported using the method-level "stateMismatch" error already defined by [JMAP-CORE] for a failed "ifInState" guard. It is returned as an "error" response in place of the Foo/set response, so no create, update, or destroy result is returned and the client knows the method made no change.¶
The error discloses no object data: a client that needs the current values fetches them with Foo/get or Foo/changes before deciding how to proceed. A server MAY include a "description" for debugging, and MAY include:¶
When the "atomic" argument is true, the server MUST apply the Foo/set as a single unit: either every creation, update, and destroy the method requests takes effect, or none of them does.¶
The server evaluates the method as though all of its changes were applied together. Any constraint that [JMAP-CORE] or a data-type extension evaluates on the resulting object set — for example a uniqueness constraint on a name within a parent — is checked once, against the state produced by applying all the changes, not against any intermediate state.¶
This is what lets a set of changes that is valid only as a whole succeed. For example, exchanging two names — renaming object A to the name currently held by B and B to the name currently held by A — collides if applied one change at a time, but the combined result is conflict-free (see Section 4.9).¶
If any part of an atomic method fails — an invalid patch, a name collision, a permission error, a violated constraint, or any other SetError that would place an id in "notCreated", "notUpdated", or "notDestroyed" — the server MUST make no change and MUST reject the whole method with a method-level "atomicFailure" error. The state string for the type MUST be unchanged.¶
A failed "ifUnchangedBy" precondition is reported as a "stateMismatch" error (Section 3.3) whether or not "atomic" is set; preconditions are already all-or-nothing at the method level. The "atomicFailure" error is specific to the method's own creations, updates, and destroys not being committable together.¶
The "atomicFailure" error object MAY carry "notCreated", "notUpdated", and "notDestroyed" maps — of the same form as a Foo/set response — identifying the ids whose processing failed and the SetError for each. Ids that would have succeeded are omitted; they were not committed, because the method as a whole was rejected. A server MAY report only the first failure it encounters.¶
A server that advertises this capability but cannot apply a given method atomically — either at all, or because this particular method is beyond what it can commit as a unit — MUST reject the method with a method-level "cannotApplyAtomically" error and make no change. It MUST NOT fall back to applying the changes non-atomically.¶
A client that receives "cannotApplyAtomically" MAY retry without "atomic" if it can tolerate partial application, or surface the failure otherwise. A client that does not recognise the error treats it as "serverFail" ([JMAP-CORE]): since a rejected atomic method makes no change, this fallback is safe, if less informative.¶
A client last synchronised a file node "f42" with content blob "G_old". It has since produced new content "G_new", and wishes to replace the content only if no other client has changed it in the meantime. It conditions on the server-set "blobId" ([JMAP-FILENODE]):¶
[[ "FileNode/set", {
"accountId": "u1",
"ifUnchangedBy": { "f42": { "blobId": "G_old" } },
"update": { "f42": { "blobId": "G_new",
"modified": "2026-05-01T09:30:00Z" } }
}, "0" ]]
¶
If "f42" still references "G_old", the update succeeds. If another client has already replaced the content (so "blobId" is now, say, "G_other"), the precondition fails and the server makes no change, returning a method-level "stateMismatch" error:¶
[[ "error", {
"type": "stateMismatch",
"failed": [ "f42" ]
}, "0" ]]
¶
The client fetches "f42", resolves the conflict (for instance by preserving its own version as a separate file), and retries.¶
Destroy a message only if it has not been read on another device. In [JMAP-MAIL], an unread message has no "$seen" keyword, so the client asserts that "keywords/$seen" is absent:¶
[[ "Email/set", {
"accountId": "u1",
"ifUnchangedBy": { "M7": { "keywords/$seen": null } },
"destroy": [ "M7" ]
}, "0" ]]
¶
If the message has since been marked "$seen", the precondition fails, the whole method is rejected with a "stateMismatch" error, and "M7" is not deleted.¶
A precondition may name an object the method is not creating, updating, or destroying. Here a client updates the content of file "f42", but only if it is still in the parent directory the client last saw ("d3"), so the write does not land in an unexpected place after a concurrent move:¶
[[ "FileNode/set", {
"accountId": "u1",
"ifUnchangedBy": { "f42": { "parentId": "d3" },
"d3": { "name": "Reports" } },
"update": { "f42": { "blobId": "G_new",
"modified": "2026-05-01T09:30:00Z" } }
}, "0" ]]
¶
If either "f42" has been reparented or "d3" has been renamed, the method is rejected with a "stateMismatch" error and the content is not written, even though "d3" itself is not being changed.¶
A data type that maintains a server-set change token can be used to require that nothing at all has changed. For example, a type with a server-maintained "changed" timestamp that is updated on every modification:¶
"ifUnchangedBy": { "f42": { "changed": "2026-04-30T12:00:00Z" } }
¶
Because the server updates "changed" on any modification to the object, this asserts that the entire object is unchanged since the client last read it, recovering the coarse "If-Match" behaviour as a special case of the general mechanism.¶
Two file nodes, "a" (named "current.txt") and "b" (named "previous.txt"), are to swap names. Applied one at a time, either rename collides with the name the other still holds; applied together, the result is conflict-free. With "atomic" set, the rule that sibling names are unique is evaluated only against the resulting state:¶
[[ "FileNode/set", {
"accountId": "u1",
"atomic": true,
"update": {
"a": { "name": "previous.txt" },
"b": { "name": "current.txt" }
}
}, "0" ]]
¶
Both renames take effect, or — if either is rejected for any reason — neither does, and the server returns an "atomicFailure" error identifying the offending id.¶
IANA is requested to register the "conditional" JMAP Capability as follows, in the "JMAP Capabilities" registry established by [JMAP-CORE]:¶
IANA is requested to register the "atomicFailure" JMAP Error Code in the "JMAP Error Codes" registry established by [JMAP-CORE]:¶
IANA is requested to register the "cannotApplyAtomically" JMAP Error Code in the "JMAP Error Codes" registry established by [JMAP-CORE]:¶
The conditional mechanism returns no object data. A failed precondition yields only a "stateMismatch" error, optionally naming the ids whose preconditions did not hold, but never their values — nothing beyond what the client could already obtain by reading the objects with Foo/get.¶
A precondition does not bypass access control. A server MUST require the same permissions to read the properties referenced in an "ifUnchangedBy" PatchObject as it would to return them from Foo/get. In particular, a server MUST NOT evaluate a precondition on a property the requesting client is not permitted to read; such a condition MUST cause the method to fail with a "forbidden" error, so that the mechanism cannot be used as an oracle to probe the values of properties the client cannot otherwise see.¶
Applying a method atomically may require the server to hold a transaction, or equivalent locks, across all of its changes, and so a very large atomic Foo/set can hold resources longer than the same changes made independently. The limits a server already applies to the number of objects in a single Foo/set ([JMAP-CORE]) continue to apply, and a server MAY reject an atomic method it considers too large rather than attempt it.¶
Evaluating a precondition is comparable in cost to validating an update PatchObject and imposes no significant additional load.¶
EDITOR: please remove this section before publication.¶
The source of this document exists on github at: https://github.com/brong/draft-gondwana-jmap-conditional/¶
draft-ietf-jmap-conditional-00¶
Upload with working-group adopted name.¶
draft-gondwana-jmap-conditional-01¶
Added the "atomic" argument to Foo/set: a method requested with "atomic" is applied as a single unit, with constraints evaluated against the resulting state (enabling changes valid only as a whole, such as an atomic name exchange).¶
Added the "atomicFailure" method-level error (the method's changes could not be committed together) and the "cannotApplyAtomically" method-level error (the server cannot apply the method atomically), both registered with IANA. A server MUST NOT apply an atomic method's changes only in part.¶
Generalised "ifUnchangedBy" to allow a precondition on any object of the method's type, including objects the method does not itself create, update, or destroy (for example a parent or container).¶
Changed the effect of a failed precondition from a per-object "stateMismatch" SetError to a method-level "stateMismatch" error that rejects the whole method and makes no change; a client wanting independent application uses separate method calls. Removed the bespoke per-object SetError definition in favour of Core's method-level "stateMismatch", optionally carrying the "failed" ids.¶
draft-gondwana-jmap-conditional-00¶
Initial proposal.¶