Zuplo API v4
Request Handling

Idempotency and Concurrency

Requests fail in ways the client cannot see. The connection drops after the server committed, a gateway times out, or an agent retries a tool call. Terraform re-applies whole plans. v4 is designed so that reads, updates and deletes can be retried without thinking about it. Two things v4 does not support: Idempotency-Key, so a retried create can make a second resource, and ETag and If-Match, so when two people (or agents) edit the same resource, the last write wins.

Everything but a create is retry-safe

V4-IDEM-1

GET, PUT, PATCH, DELETE and the POST that adds an association can be retried safely. PATCH sets absolute values, and GET has no side effects.

Review
GETSafe

No side effects.

PUTIdempotent

The body is the full new state of a resource whose key the client chose.

PATCHIdempotent

Merge patch sets absolute values (see below).

DELETEIdempotent

A repeat returns 404, which the client treats as success.

POSTIdempotent for an association

The key is in the body, so a repeat replaces the same association.

POSTNot idempotent for a create

A retry can create a second resource. There is no Idempotency-Key.

The rules that keep this true:

Do
PATCH sets an absolute value
JSONCode
{ "value": 600 }
Don't
PATCH a relative change
JSONCode
{ "increaseBy": 100 }

Anything relative is a POST that creates something.

V4-IDEM-4

When the client knows the identity of what it creates (a person's role on a pool, a person's budget), the request carries that key, and it replaces the existing one instead of adding a second. The key is in the body of the POST that adds an association, or in the URL of the PUT that sets one, or a person's singleton.

Review
Do
Send the natural key
Code
POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members Content-Type: application/json { "userId": "user_Jm5tR8wQz2KcX7vB4nLp9HdS", "role": "admin" }
Code
PUT /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/user_Jm5tR8wQz2KcX7vB4nLp9HdS/budget Content-Type: application/json { "limits": […] }

Repeating either request changes nothing.

Don't
Give the association its own ID
Code
HTTP/1.1 201 Created Location: /v4/buckets/{bucketId}/ai/pools/{poolId}/members/mbr_…

A retry creates a second one.

No side effects on GET. Nothing is created, sent or counted by a read.

No Idempotency-Key

V4-IDEM-2

No operation declares an Idempotency-Key header. v4 does not store and replay the response of a POST, and a header that the server ignores promises a retry safety that does not exist.

Lintv4-idempotencyShared rule: ROB-1

The shared guidelines (ROB-1) require the header, as in the IETF draft, for creating or charging POSTs. v4 does not apply that rule (decided on 2026-11-05, question 9).

Do
A create without the header
YAMLCode
post: operationId: create-ai-pool requestBody: …
Don't
A header the server ignores
YAMLCode
post: operationId: create-ai-pool parameters: - $ref: "#/components/parameters/IdempotencyKey"

A client that trusts it still creates duplicates.

A POST whose response was lost may or may not have been applied, and may still be running. Where the client knows the identity of what it creates, the request carries that key and replaces an existing association (V4-IDEM-4), so this case does not come up. Otherwise a retried create can always duplicate. Look the resource up before retrying: by its name or label where that is unique (a pool, a provider assignment, a consumer), or as the newest item of the list. This catches a create that already completed, but not one that is still in flight, so it makes a duplicate less likely, not impossible:

Do
Check before retrying
Code
create pool "Engineering" → connection lost list pools → not there, so create it again
Don't
Retry blindly
Code
create pool "Engineering" → connection lost create pool "Engineering" → a second "Engineering" pool

No ETag or If-Match

V4-IDEM-3

No operation declares an If-Match header, an ETag response header or a 412 response. v4 does not check a write against the version the client read, and a header that the server ignores promises a protection from lost updates that does not exist.

Lintv4-conditional-requestsShared rule: ROB-2

The shared guidelines (ROB-2) require conditional requests for "config-like" resources, and most v4 resources, such as an app or a pool, are rich configuration. v4 does not apply that rule: the last write wins (decided on 2026-11-05, question 14).

Do
A write without the header
YAMLCode
patch: operationId: update-ai-pool requestBody: …
Don't
A header the server ignores
YAMLCode
patch: operationId: update-ai-pool parameters: - $ref: "#/components/parameters/IfMatch"

A client that trusts it still overwrites someone else's change.

A write replaces whatever someone else changed since you read the resource. PATCH leaves the fields you leave out alone, so send only the ones you change. An array is replaced as a whole, so read it right before you write it:

Do
Send only what you change
Code
PATCH /v4/buckets/{bucketId}/ai/pools/{poolId} Content-Type: application/json { "name": "Platform" }
Don't
Send back everything you read
Code
PATCH /v4/buckets/{bucketId}/ai/pools/{poolId} Content-Type: application/json { "name": "Platform", "budgetRules": […], "policyTemplate": […] }

This undoes any change to the budget rules or the policy template made since your read.

Lint
Fails CI on any finding
v4-idempotencyv4-conditional-requests

No operation declares Idempotency-Key, If-Match, ETag or 412.

Review
Checked by people in the pull request

That PATCH sets absolute values and GET has no side effects (V4-IDEM-1), and that an association's natural key is in the body of its POST or the URL of its PUT (V4-IDEM-4).

Every rule ID and what enforces it: the rule index
Last modified on