A SCIM 2.0 provisioning client times out and resends the same PATCH to your viewer — which operations replay safely, and which do not?
answer
- assume the second delivery
- three operations, three characters
- sets, not lists
- the correct error that looks like failure
- a version check is not a replay check
basics
~20 sReplace replays cleanly because it writes an absolute value. Remove converges too, though the second attempt may match nothing and the specification's answer is a 400 with scimType noTarget. Add is the dangerous one: repeating it duplicates a multi-valued element unless you apply it as a set keyed on value.
solid answer
~50 sAssume every write arrives twice. A client cannot tell a lost request from a lost response, so it retries, and your endpoint must converge either way. `replace` is the easy case: it writes an absolute value, so applying it twice leaves the same state. `remove` also converges, but the second attempt matches nothing, and RFC 7644 says a `remove` whose multi-valued target matches no element returns **400** with `scimType` `noTarget` — a correct answer that reads as a failure to the client, which is why many service providers choose to treat it as a no-op instead. `add` is the one that bites: adding the same member to a practice's group twice duplicates the element unless you apply multi-valued attributes as sets keyed on `value`. Conditional requests using `meta.version`, `ETag` and `If-Match` help where clients send them — most do not, so convergence must be in your handler.
code
pseudocode · 20 linesapply(patchOp, resource):
begin transaction
for operation in patchOp.Operations: # ordered, not a set
target = resolve(operation.path, resource) # value paths become predicates
if operation.op == "add" and target.isMultiValued:
for element in operation.value:
if not target.containsKey(element.value): # set semantics, keyed on value
target.insert(element)
# already present: nothing to do
else if operation.op == "replace":
target.set(operation.value) # absolute write, replays cleanly
else if operation.op == "remove":
removed = target.deleteMatching()
if removed == 0:
return conflictPolicy.forEmptyRemove() # noTarget, or success by decision
commit
return resourcego deeper
Remember that a provisioning client resends when it does not hear back, so the same change can arrive twice, and that adding someone to a group twice is the case that quietly goes wrong.
Explain the three operations' different replay characters, and why a multi-valued add needs set semantics keyed on the member identifier while a replace needs nothing because it writes an absolute value.
Show the convergence living in storage — a unique constraint on the membership relation, value paths resolved to predicates, the whole operation list in one transaction — and state your chosen answer for a removal that matches nothing.
Weigh the specification's noTarget against a client fleet that paints it red. Deciding to diverge is legitimate; deciding it per endpoint, undocumented, is how a product acquires behaviour nobody can explain in two years.
## Why you must assume the same write arrives twice A provisioning client sits behind a queue, a scheduler and a network. When its call to your radiography viewer times out, it has learned nothing: the request may never have reached you, or it may have been applied and the response lost on the way back. The only safe move available to it is to send the operation again, and every real client does. Your endpoint therefore has to make the second application harmless, because nothing on the client's side can. This is not the generic "make your endpoint idempotent" advice. SCIM's PATCH has three operations with three different replay characters, and the interview question is which is which. ## The three operations under replay | op | replays to the same state? | what the second attempt does | what it costs you | |---|---|---|---| | `replace` | yes, when the value is absolute | overwrites with the same value | nothing | | `remove` | yes in state | matches nothing; the specification's answer is 400 with `scimType` `noTarget` | a correct answer that looks like a failure | | `add` | **no**, on a multi-valued attribute | can append a second, identical element | a duplicated member, silently | **`replace`** is idempotent in the state it leaves, provided the value is absolute rather than relative — and in SCIM it always is, because there is no increment or append form of `replace`. Applying it twice leaves the resource as it was after the first. **`remove`** converges too: after either one or two applications the element is gone. The wrinkle is what you answer. RFC 7644 is explicit that a `remove` targeting a multi-valued attribute that matches no element returns a 400 with `scimType` `noTarget`. That is a *correct* response to a *harmless* request, and the client's dashboard paints it red. Many service providers therefore choose to answer a second removal as a success, on the grounds that the end state the client asked for is the end state it has. That choice is a deliberate divergence, and it belongs in whatever record you keep of the divergences you have accepted — not in a quiet conditional that nobody remembers writing. **`add`** is the one that goes wrong quietly. On a single-valued attribute it behaves like a replace. On a multi-valued attribute the specification's intent is that it *adds to the set*, and a naive implementation appends. Send the same "add this hygienist to the Wednesday rota" twice and the group holds the person twice. Nothing errors. The duplicate then surfaces months later as a doubled row in a member list, a doubled licence count, or a removal that appears not to work because it took one of the two elements away. ## Making the handler converge The fix is structural, not a retry table: 1. **Treat multi-valued attributes as sets keyed on the identifying sub-attribute.** For group membership that is `value`, the identifier of the member resource. Check membership before you insert, or put a unique constraint on the membership relation and let the database do it. 2. **Resolve value paths to predicates.** A targeted delete against a predicate that matches nothing is naturally a no-op; a read-modify-write of the whole membership is not. 3. **Apply the whole `Operations` list in one transaction.** A replay that lands half-applied is worse than either outcome, because now neither side knows the state. 4. **Decide, once and in writing, what you answer for a `remove` that matches nothing** — the specification's `noTarget` or a success — and apply that decision uniformly rather than per-endpoint. ## Where conditional requests fit, and where they do not SCIM carries an optional versioning story. A resource's `meta.version` is returned in the body and echoed as an `ETag`; a client that holds one can send `If-Match` on an update, and you answer **412** when the version it holds is stale. This is genuinely useful — it turns a lost update into a visible conflict the client can re-read and retry. But it solves a different problem from replay, and you cannot lean on it for this one: - It is optional on both sides. `/ServiceProviderConfig` has an `etag` capability precisely because a service provider may not support it, and a great many provisioning clients never send `If-Match` even when you do. - It detects *concurrent modification*, not *duplicate delivery*. A replayed operation against an unchanged resource carries the version that still matches, so `If-Match` passes and the operation applies again. So the order of defence is: converge in the handler first, offer conditional requests second. A service provider whose only answer to "what happens when the client resends?" is "they should send `If-Match`" has described a control the other side is not obliged to use, on a channel where the other side is software it cannot change. ## What good looks like at 3 a.m. The customer's administrator reports that a hygienist appears twice on a rota. You want to be able to answer that in one query — the membership relation has a unique constraint, so it cannot happen — rather than by reconstructing which of last night's retries landed twice. The convergence is what makes the incident boring.
- Why does an If-Match precondition not solve the duplicate-delivery problem?Because a replay of an operation that already applied usually carries a version that still matches. If the resource has not changed since, the precondition passes and the operation is applied a second time. `If-Match` detects a *competing* write, not a *repeated* one. The two failure modes need different answers: convergence in the handler for replay, a version check for concurrency.
- You decide to answer a remove that matches nothing with a success rather than noTarget. What must go with that decision?A written record of it as an accepted divergence, applied uniformly across every resource type, with a test that pins the behaviour so nobody 'fixes' it back. Log the empty removal so a silently no-op sync is still visible to you. The risk you accept is that a client bug sending removals against the wrong target becomes invisible, so the log is not optional.
- The client retries a create rather than a PATCH. Does the same reasoning apply?The same pressure, a different answer. A resent create collides on the unique attribute and the contract is a conflict response naming uniqueness, which is correct for both a genuine duplicate and a retry. The client resolves it by looking the person up and switching to an update. You do not need create-side deduplication state; the uniqueness constraint already is it.
A courier who never hears back re-delivers the parcel. If the instruction was 'the box on the step should contain this', the second delivery changes nothing. If it was 'put one more in', you now have two — and the difference is in how the instruction was written, not in how carefully the courier drove.
saying these in an interview costs you the question
- Says PATCH is idempotent because it is a PATCH
- Appends to a multi-valued attribute without checking membership
- Relies on clients sending If-Match to prevent duplicates
- Treats 400 noTarget as proof the client is broken
- Stores a retry key per operation instead of converging the state
- Applies a PatchOp operation by operation without a transaction