Skip to main content
Experimental

IPA-132: Long-Running Operations

A long-running operation (LRO) is an operation whose completion is not guaranteed within a single HTTP request/response cycle. Rather than holding a request open until the work finishes, the API accepts the request, returns a handle to the work immediately, and lets the client observe progress asynchronously by polling a dedicated Operation resource.

Classification is structural, decided at design time: an operation is long-running because of what it does (it hands work to a background worker, or its worst-case time scales with an uncapped input), not because a particular request happened to be slow. Slowness that can be fixed (caching, an index, query optimization) is a performance bug, not an LRO.

Guidance

Classifying an operation as long-running

  1. An operation must be classified as long-running at design phase when its completion is not guaranteed within a single request/response cycle, or when it is executed asynchronously (for example via a planner, job queue, or workflow engine).

    paths:
    /orders:
    post:
    operationId: createOrder
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    schema:
    type: string
    format: uri
    Why:

    Creating an order hands work to a background worker, so completion is not guaranteed in-cycle; the method is correctly modeled as an LRO and must only return 202 Accepted and a Location header.

    paths:
    /users:
    post:
    operationId: createUser
    responses:
    "201":
    description: Created
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/User"
    Why:

    Creating a user is performed as a single insert that is expected to complete within the request, so the client does not need any operation handle to poll and the method should not be modeled as an LRO.

    1. For each mutating operation (POST, PUT, PATCH, DELETE, or a custom method), determine whether the handler can complete the work on the request thread or must hand it to a background worker (planner, job queue, workflow engine).

    2. Determine whether the worst-case completion time scales with an input that is not capped (fan-out, snapshot size, queue depth, an external system). An uncapped input is a strong LRO signal.

    3. If either signal holds, the operation is long-running and must follow this principle. If the operation is merely slow but bounded and fixable, treat it as a performance issue, not an LRO.

    4. Confirm a long-running operation can only return 202 Accepted.

  2. A Get method must not be an LRO.

    • Get method returns the current state of a single resource that already resides on the server.
    paths:
    /orders/{orderId}:
    get:
    operationId: getOrder
    responses:
    "200":
    description: OK
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/Order"
    Why:

    Fetching an order by ID reads state that already exists on the server and returns it synchronously with 200 OK, so there is no background work to track and the method is correctly not modeled as an LRO.

    paths:
    /orders/{orderId}:
    get:
    operationId: getOrder
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    description: URI of the Operation resource to poll.
    schema:
    type: string
    format: uri
    Why:

    Fetching an order by ID reads state that already exists on the server, so modeling the Get method as a long-running operation adds an Operation handle where no background work exists to track.

  3. A List method must not be an LRO.

    • List method returns data from a collection that already exists on the server.
    paths:
    /orders:
    get:
    operationId: listOrders
    responses:
    "200":
    description: OK
    content:
    application/json:
    schema:
    type: object
    properties:
    results:
    type: array
    items:
    $ref: "#/components/schemas/Order"
    Why:

    Listing orders returns a finite page of records that already exist on the server, so the response is returned in-cycle with 200 OK and does not need an operation handle.

    paths:
    /orders/{orderId}:
    get:
    operationId: listOrders
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    description: URI of the Operation resource to poll.
    schema:
    type: string
    format: uri
    Why:

    Listing orders returns a page of existing collection data, so modeling the List method as a long-running operation incorrectly turns a synchronous read into asynchronous work.

Initiating a long-running operation

A method that starts long-running work acknowledges the request immediately and returns a handle the client uses to observe it.

  1. A method that starts a long-running operation must return 202 Accepted with a Location header pointing at the Operation resource URI that clients poll for status.

    paths:
    /orders:
    post:
    operationId: createOrder
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    description: URI of the Operation resource to poll.
    schema:
    type: string
    format: uri
    Why:

    Creating an order starts background work, so the method acknowledges the request with 202 Accepted and hands the client the exact Operation URI to poll in the Location header, providing one unambiguous way to obtain the handle.

    paths:
    /orders:
    post:
    operationId: createOrder
    responses:
    "202":
    description: Accepted
    Why:

    Creating an order starts long-running work, but the method returns 202 Accepted without a Location header, so the client has no Operation resource URI to poll and no standard way to observe progress.

  2. A method modeled as a long-running operation must not advertise any other 2XX success status code besides 202 Accepted.

    paths:
    /orders:
    post:
    operationId: createOrder
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    description: URI of the Operation resource to poll.
    schema:
    type: string
    format: uri
    Why:

    Because the method is correctly marked as an LRO and limits its success response to 202 Accepted, the contract for starting work remains definitive and declarative tooling can consistently depend on this uniform behavior.

    paths:
    /orders:
    post:
    operationId: createOrder
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    schema:
    type: string
    format: uri
    "201":
    description: Created
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/Order"
    Why:

    The same long-running operation advertises two success codes, so clients cannot reliably tell whether they should expect a body or a handle and automated validation cannot enforce a single contract.

    1. For each Long-Running operation, collect all 2XX responses.

    2. Confirm the only 2XX response is 202 Accepted.
    3. Flag any long-running operation that advertises 200, 201, 204, or any other 2XX code apart from 202.

  3. The 202 Accepted response must not contain content.

    • The handle to the operation is carried by the Location header, not the body.
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    schema:
    type: string
    format: uri
    Why:

    The 202 Accepted response carries only the Location header and no body, so there is a single, unambiguous way for the client to obtain the operation handle.

    responses:
    "202":
    description: Accepted.
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/Order"
    Why:

    The 202 Accepted response returns an Order body alongside the handle, implying the resource already exists and creating a second, conflicting source of truth alongside the Operation resource.

  4. The API must perform all applicable request validation and authorization before accepting the request for long-running work, so invalid or unauthorized requests still receive a synchronous 4xx and never a 202.

    paths:
    /orders:
    post:
    operationId: createOrder
    responses:
    "202":
    description: Accepted
    "400":
    description: Bad Request
    "401":
    description: Unauthorized
    "403":
    description: Forbidden
    Why:

    The order create validates the payload and caller permissions on the request thread and only then enqueues the background work, so invalid or unauthorized requests receive synchronous 4xx responses and 202 Accepted is reserved for real work.

    paths:
    /orders:
    post:
    operationId: createOrder
    responses:
    "202":
    description: Accepted
    Why:

    The order create accepts every request unconditionally and leaves the background worker to discover invalid input, so clients must poll an Operation only to learn the request was malformed, instead of getting a clear synchronous 4xx.

    1. For each long-running method, confirm the operation declares the synchronous error responses that apply (400, 401, 403, 409, 422, …).

    2. Confirm the description or design makes clear that validation and authorization run before the request is accepted for asynchronous work.

    3. Flag any endpoint that returns 202 unconditionally and reports validation or authorization failures only through the Operation resource.

The Operation resource

A long-running operation is observed through a dedicated, read-only Operation resource exposed on its parent.

  1. An operation marked as long-running must expose its status through an /operations endpoint on its parent resource.

    • If the long-running operation is initiated from a collection-level method and the resource instance does not yet exist, the Operation resource must be exposed at the collection level.
    • If the long-running operation is initiated for an existing resource instance, the Operation resource must be exposed at the instance level.
    paths:
    /orders:
    post:
    operationId: createOrder
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    schema:
    type: string
    format: uri
    /orders/operations:
    get:
    operationId: listOrdersOperations
    /orders/operations/{operationId}:
    get:
    operationId: getOrdersOperation
    Why:

    A collection-level create that is long-running exposes its Operation resources under /orders/operations and /orders/operations/{operationId}, so the handle in the Location header always points at a concrete Operations endpoint in the contract.

    paths:
    /orders/{orderId}:
    patch:
    operationId: updateOrder
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    schema:
    type: string
    format: uri
    /orders/{orderId}/operations:
    get:
    operationId: listOrderOperations
    /orders/{orderId}/operations/{operationId}:
    get:
    operationId: getOrderOperation
    Why:

    A long-running update on an existing order instance exposes its Operation resources under /orders/{orderId}/operations and its instance-level counterpart /orders/{orderId}/operations/{operationId}, making the parent–child relationship explicit.

    paths:
    /orders:
    post:
    operationId: createOrder
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    schema:
    type: string
    format: uri
    Why:

    The createOrder method is marked long-running and returns a Location header, but no /orders/operations endpoints are defined, so the handle points at a resource that does not exist in the contract.

    1. Derive the parent resource path for each operation.
    2. If the long-running operation is initiated at collection level and no resource instance exists yet, confirm the paths object defines <parent>/operations and <parent>/operations/{operationId}.

    3. If the long-running operation is initiated on an existing resource instance, confirm the paths object defines <parent>/{resourceId}/operations and <parent>/{resourceId}/operations/{operationId}.

    4. Flag any resource that tracks instance-level long-running operations only at collection scope, either by pointing the Location header at a collection-scoped Operation or by omitting the instance-scoped Operations endpoints.

  2. Operations resource must be a read-only resource.

    paths:
    /orders/{orderId}/operations:
    get:
    operationId: listOrderOperations
    /orders/{orderId}/operations/{operationId}:
    get:
    operationId: getOrderOperation
    Why:

    The Operations endpoints expose only GET methods under /orders/{orderId} /operations and /orders/{orderId}/operations/{operationId}, matching the read-only, backend-managed lifecycle of Operation records.

  3. Operations endpoints must be defined as nested paths under the parent resource's own path, at both the collection and instance level.

    note

    Compatibility note with IPA-102: The /operations and /operations/{operationId} segments are a standardized LRO suffix.

    paths:
    /groups/{groupId}/orders/operations:
    get:
    operationId: listOrdersOperations
    /groups/{groupId}/orders/{orderId}/operations:
    get:
    operationId: listOrderOperations
    Why:

    Order operations are exposed under /groups/{groupId}/orders/operations and /groups/{groupId}/orders/{orderId}/operations, so they remain nested under the exact same path as their parent resource.

    paths:
    /groups/{groupId}/orders/{orderId}:
    patch:
    operationId: updateOrder
    responses:
    "202":
    description: Accepted
    headers:
    Location:
    schema:
    type: string
    format: uri
    /orgs/{orgId}/orders/{orderId}/operations:
    get:
    operationId: listOrderOperations
    /orgs/{orgId}/orders/{orderId}/operations/{operationId}:
    get:
    operationId: getOrderOperation
    Why:

    These Operations endpoints sit at a path detached from the parent order, namely /orgs/{orgId}/orders/{orderId}/operations, instead of nested under the order's own path at /groups/{groupId}/orders/{orderId}, so the Operations endpoint no longer stems from its parent resource's path.

    1. Identify the parent resource's own path for each long-running operation.

    2. Confirm the corresponding Operations endpoints are defined as nested paths directly under that parent resource path, at both the collection and instance level.

    3. Flag any Operations endpoint whose path does not stem from its parent resource's own path.

  4. Operations endpoints must not be defined as standalone, global endpoints with no parent resource in their path.

    paths:
    /groups/{groupId}/orders/operations:
    get:
    operationId: listOrdersOperations
    /groups/{groupId}/orders/operations/{operationId}:
    get:
    operationId: getOrdersOperation
    Why:

    The Operations endpoints are nested under the /groups/{groupId}/orders collection path, so they are not exposed as standalone, parentless endpoints.

    paths:
    /groups/{groupId}/orders/operations:
    get:
    operationId: listOrdersOperations
    /orders/operations:
    get:
    operationId: listAllOrderOperations
    Why:

    A single global /orders/operations endpoint has no parent resource in its path, so it is detached from the resource hierarchy the operation belongs to.

    1. Collect every path ending in /operations or /operations/{operationId}.

    2. Flag any such path mounted at the API root or with no parent resource segment preceding it.

    3. Confirm each Operations endpoint's path is nested under a parent resource path.

  5. Operations endpoints must be leaf resources. An operations segment may only be followed by a single operation identifier path parameter.

    paths:
    /groups/{groupId}/orders/operations:
    get:
    operationId: listOrdersOperations
    /groups/{groupId}/orders/operations/{operationId}:
    get:
    operationId: getOrdersOperation
    Why:

    The Operations endpoints end at the operations collection and at the single operation identified by {operationId}, so no resources are nested below the Operations resource.

    paths:
    /groups/{groupId}/orders/operations/{operationId}/logs:
    get:
    operationId: listOrdersOperationLogs
    Why:

    The logs resource is nested below a single Operation, so the Operations endpoint is no longer a leaf resource.

    1. Collect every path containing an operations segment.

    2. Confirm each such path ends at the operations segment or at a single operation identifier path parameter directly following it.

    3. Flag any path that nests further segments below the operations segment or below its operation identifier path parameter.

  6. Operations endpoints must use the same authorization model as read access on the parent resource. They must not introduce broader access, stronger permissions, or a separate permission model.

    paths:
    /groups/{groupId}/orders/{orderId}:
    get:
    operationId: getOrder
    x-rolesRequirements:
    - Project Read Only
    /groups/{groupId}/orders/{orderId}/operations/{operationId}:
    get:
    operationId: getOrderOperation
    x-rolesRequirements:
    - Project Read Only
    Why:

    Reading an order operation requires the same role set as reading the parent order, so the Operations endpoint does not introduce a separate authorization surface.

    paths:
    /groups/{groupId}/orders/{orderId}:
    get:
    operationId: getOrder
    x-rolesRequirements:
    - Project Read Only
    /groups/{groupId}/orders/{orderId}/operations/{operationId}:
    get:
    operationId: getOrderOperation
    x-rolesRequirements:
    - Project Owner
    Why:

    Reading the Operation requires a stronger role than reading the parent resource, so the Operations endpoint introduces a different authorization model from the parent read.

Communicating the Long Running Operation Status

  1. Operation endpoints must return OperationResponse to report long-running operation status.

    paths:
    /orders/{orderId}/operations/{operationId}:
    get:
    operationId: getOrderOperation
    responses:
    "200":
    description: Operation status.
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/OperationResponse"
    Why:

    The Operation endpoint returns the standard OperationResponse schema, so clients have one consistent contract for reading long-running operation state.

    paths:
    /orders/{orderId}/operations/{operationId}:
    get:
    operationId: getOrderOperation
    responses:
    "200":
    description: Operation status.
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/Order"
    Why:

    The Operation endpoint returns an Order resource instead of OperationResponse, so the long-running operation status contract is not represented through the standard Operation schema.

  2. An Operation must report progress through a status field, using exactly this enum:

    • PENDING — the request was accepted but work has not started.
    • IN_PROGRESS — work is currently executing.
    • SUCCEEDED — work completed successfully. This is the only success terminal state.
    • FAILED — work completed unsuccessfully and reports a structured error.
    • CANCELED — work was terminated on request before completion.
    • SUPERSEDED — work was replaced by a later operation on the same resource.
    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    status:
    type: string
    enum: [PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED]
    Why:

    The OperationResponse schema reports its lifecycle through a single status field whose enum is exactly [PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED], so a generic client can reason about any operation the same way and SUCCEEDED is the single success terminal.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    state:
    type: string
    enum: [QUEUED, RUNNING, COMPLETE, ERROR, CANCELLED]
    Why:

    The schema renames the field to state and invents a different enum, so a generic client cannot map these values to the standard lifecycle and even the spelling CANCELLED diverges from the canonical set.

  3. A FAILED operation must report the failure through a structured error object carrying a code, a message, a retryable flag, an optional retryStrategy, and optional details.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    error:
    $ref: "#/components/schemas/OperationError"
    OperationError:
    type: object
    properties:
    code:
    type: string
    message:
    type: string
    retryable:
    type: boolean
    retryStrategy:
    type: string
    enum: [IMMEDIATE, BACKOFF, NONE]
    details:
    type: object
    Why:

    A failed order create returns a structured OperationError with code, message, retryable, plus optional retryStrategy, and optional details, so a client can decide whether to retry, back off, or escalate without parsing free text.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    error:
    type: string
    Why:

    A failed order create represents its failure as a plain error string, forcing every client to parse prose and giving no machine-readable signal of whether the failure is retryable.

    1. Find the Operation schema and its error property.

    2. Confirm error is an object with code, message, retryable, with retryStrategy and details optional.

    3. Flag operations that model failure as a plain string or omit the retryable signal.

  4. On SUCCEEDED, an Operation must expose a resultHref pointing at the completed resource, so a client can follow the operation straight to its result.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    status:
    type: string
    enum: [PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED]
    resultHref:
    type: string
    format: uri
    Why:

    A succeeded order create sets resultHref to the finished order's URI, so the client can follow the Operation directly to the completed resource instead of reconstructing the URL.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    status:
    type: string
    enum: [PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED]
    Why:

    The succeeded order create exposes only a status field and no resultHref, so the client must guess where the completed resource lives.

  5. An Operation should expose statusMessage, progress, and estimatedCompletionTime so clients can reason about in-flight work.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    statusMessage:
    type: string
    progress:
    type: object
    properties:
    completed:
    type: number
    total:
    type: number
    unit:
    type: string
    estimatedCompletionTime:
    type: string
    format: date-time
    Why:

    An in-flight order operation provides a human-readable statusMessage and quantitative progress so the client can see how far along the work is.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    status:
    type: string
    enum: [PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED]
    Why:

    The in-flight order operation exposes only the status enum, giving the client no progress signal or poll-cadence hint and making it hard to choose sensible polling behaviour.

  6. While an Operation is in a non-terminal state (PENDING or IN_PROGRESS), it must expose retryAfterSeconds, the suggested minimum poll interval in seconds, so clients know how often to poll rather than guessing. Once an Operation reaches a terminal state, retryAfterSeconds must be omitted, since there is no further work to poll for.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    status:
    type: string
    enum: [PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED]
    retryAfterSeconds:
    type: integer
    Why:

    An in-flight order operation exposes retryAfterSeconds alongside status, so the client has a concrete poll-cadence hint instead of guessing an interval on its own; the field is omitted once the operation reaches a terminal state.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    status:
    type: string
    enum: [PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED]
    Why:

    The in-flight order operation exposes only the bare status enum, giving the client no poll-cadence hint and risking either overly aggressive polling or needlessly long waits.

  7. Operation records must be transient: they must expire after a finite retention period, exposed via an expiresAt timestamp on the Operation schema indicating when this will happen. After expiry the Operations endpoint returns 404; the underlying resource read is unaffected.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    expiresAt:
    type: string
    format: date-time
    description:
    When this operation record expires and returns 404. Default
    retention is ~30 days after reaching a terminal state.
    Why:

    The order operation record exposes an expiresAt timestamp that tells the client when the record will return 404, so operation history remains transient and storage does not grow without bound.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    createdAt:
    type: string
    format: date-time
    Why:

    The order operation record has a createdAt time but no expiry, implying operation history is retained forever instead of expiring after a finite retention period.

    1. Confirm the Operation schema exposes an expiry timestamp (for example expiresAt) and that the implementation enforces a finite TTL on terminal records.

    2. Confirm expired operations return 404 from the Operations endpoint while the parent resource read is unaffected.

    3. Flag operations retained indefinitely or with no expiry signal.

  8. An OperationResponse must include stable core metadata for the operation record: a required operationId, a required operationType using exactly the enum CREATE, UPDATE, DELETE, CUSTOM, a customMethod that is required when operationType is CUSTOM and must be omitted otherwise, a required createdAt, and a required updatedAt.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    operationId:
    type: string
    operationType:
    type: string
    enum: [CREATE, UPDATE, DELETE, CUSTOM]
    customMethod:
    type: string
    createdAt:
    type: string
    format: date-time
    updatedAt:
    type: string
    format: date-time
    Why:

    The OperationResponse exposes a stable identifier, a standard operation kind, and record timestamps, so generic clients and tooling can identify the operation, understand what kind of work it represents, and tell when its status last changed.

    components:
    schemas:
    OperationResponse:
    type: object
    properties:
    operationType:
    type: string
    enum: [CREATE, UPDATE, DELETE, CUSTOM]
    createdAt:
    type: string
    format: date-time
    Why:

    Missing operationId and updatedAt means consumers cannot uniquely track the record or determine the freshness of the reported status.

    1. Locate the schema defined for Operation results.

    2. Verify that operationId, operationType, createdAt, and updatedAt are required fields.

    3. Confirm the operationType enum matches exactly [CREATE, UPDATE, DELETE, CUSTOM].

    4. Ensure customMethod is strictly tied to CUSTOM operations and omitted for others.

Reading a resource during a long-running operation

  1. Reads on the resource itself (Get and List) must not be used to convey operation status. A resource read returns the resource's current stored state; operation status is the Operation endpoint's responsibility.

    paths:
    /orders/{orderId}:
    get:
    operationId: getOrder
    responses:
    "200":
    description: OK
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/Order"
    /orders/{orderId}/operations/{operationId}:
    get:
    operationId: getOrderOperation
    responses:
    "200":
    description: Operation status.
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/OperationResponse"
    Why:

    The order read at /orders/{orderId} returns only the stored Order state, while operation status is exposed separately at /orders/{orderId}/operations/ {operationId}, so resource representation and Operation lifecycle stay cleanly separated.

    paths:
    /orders/{orderId}:
    get:
    operationId: getOrder
    responses:
    "200":
    description: OK
    content:
    application/json:
    schema:
    allOf:
    - $ref: "#/components/schemas/Order"
    - type: object
    properties:
    operationStatus:
    type: string
    enum: [PENDING, IN_PROGRESS, SUCCEEDED, FAILED]
    Why:

    The order read embeds an operationStatus field alongside the Order schema, coupling the resource representation to LRO lifecycle concerns and giving clients two competing sources of truth for progress.

    1. Inspect the Get and List response schemas of resources that support long-running mutations.

    2. Confirm they carry only resource state, not operation-lifecycle fields (operation status, progress, operation error).

    3. Flag resource reads that surface operation status instead of leaving it to the Operation endpoint.

  2. Changes to a resource initiated by a long-running operation must not be applied or persisted until the operation reaches a successful terminal state. If the operation fails, the resource representation must reflect its last successfully applied state.

    GET /groups/{groupId}/orders/{orderId}
    200 OK
    { "orderId": "order-1", "quantity": 10 }

    PATCH /groups/{groupId}/orders/{orderId}
    { "quantity": 25 }
    202 Accepted
    Location: /groups/{groupId}/orders/{orderId}/operations/op-1

    GET /groups/{groupId}/orders/{orderId}/operations/op-1
    200 OK
    { "operationId": "op-1", "status": "IN_PROGRESS" }

    GET /groups/{groupId}/orders/{orderId}
    200 OK
    { "orderId": "order-1", "quantity": 10 }

    GET /groups/{groupId}/orders/{orderId}/operations/op-1
    200 OK
    { "operationId": "op-1", "status": "SUCCEEDED" }

    GET /groups/{groupId}/orders/{orderId}
    200 OK
    { "orderId": "order-1", "quantity": 25 }
    Why:

    While the operation is IN_PROGRESS the order still reads as 10; the requested change only becomes visible once the operation reaches SUCCEEDED.

    GET /groups/{groupId}/orders/{orderId}
    200 OK
    { "orderId": "order-1", "quantity": 10 }

    PATCH /groups/{groupId}/orders/{orderId}
    { "quantity": 25 }
    202 Accepted
    Location: /groups/{groupId}/orders/{orderId}/operations/op-1

    GET /groups/{groupId}/orders/{orderId}/operations/op-1
    200 OK
    { "operationId": "op-1", "status": "FAILED", "error": { ... } }

    GET /groups/{groupId}/orders/{orderId}
    200 OK
    { "orderId": "order-1", "quantity": 10 }
    Why:

    The operation reached FAILED, so the requested quantity change was never persisted and the order still reads as 10, its last successfully applied state.

    GET /groups/{groupId}/orders/{orderId}
    200 OK
    { "orderId": "order-1", "quantity": 10 }

    PATCH /groups/{groupId}/orders/{orderId}
    { "quantity": 25 }
    202 Accepted
    Location: /groups/{groupId}/orders/{orderId}/operations/op-1

    GET /groups/{groupId}/orders/{orderId}/operations/op-1
    200 OK
    { "operationId": "op-1", "status": "FAILED", "error": { ... } }

    GET /groups/{groupId}/orders/{orderId}
    200 OK
    { "orderId": "order-1", "quantity": 25 }
    Why:

    The order reports the requested quantity of 25 even though the operation failed, so the resource exposes a change that was never successfully applied and clients cannot tell which state is authoritative.

    1. For each long-running mutation, identify the point at which the implementation writes the requested changes to the resource's durable store.

    2. Confirm that write happens only once the operation reaches a successful terminal state, and that a failed operation leaves the stored resource at its last successfully applied state.

    3. Flag any long-running mutation that writes requested changes before the work succeeds, or that leaves partially applied changes readable after a failure.

OperationResponse schema

{
// Required. Unique identifier for this Operation.
"operationId": "string",

// Required. Enum: PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED.
"status": "PENDING",

// Required. Enum: CREATE, UPDATE, DELETE, CUSTOM.
"operationType": "CREATE",

// Required when operationType is CUSTOM; omitted otherwise.
"customMethod": "promoteCluster",

// Optional human-readable progress note. Not for parsing.
"statusMessage": "string",

// Optional quantitative progress.
"progress": {
// Optional completed units.
"completed": 42,
// Optional total units.
"total": 100,
// Optional unit label (for example "nodes", "shards", "percent").
"unit": "nodes"
},

// Present on FAILED only.
"error": {
// Required. Stable, programmatic error code.
"code": "string",
// Required. Human-readable summary of the failure.
"message": "string",
// Required. Whether the client may safely retry.
"retryable": true,
// Optional. Enum: IMMEDIATE, BACKOFF, NONE.
"retryStrategy": "BACKOFF",
// Optional. Structured metadata with provider-specific fields.
"details": {}
},

// Required on SUCCEEDED. URL of the completed resource.
"resultHref": "https://api.example.com/...",

// Required while non-terminal (PENDING or IN_PROGRESS). Must be omitted once the operation reaches a terminal state.
"retryAfterSeconds": 30,

// Optional non-binding completion estimate.
"estimatedCompletionTime": "2026-07-01T12:34:56Z",

// Required. Creation timestamp of the Operation record.
"createdAt": "2026-07-01T12:00:00Z",

// Required. Last status update timestamp.
"updatedAt": "2026-07-01T12:01:00Z",

// Required. When the Operation record expires and returns 404. Default retention ~30 days after terminal state.
"expiresAt": "2026-08-01T12:00:00Z"
}

Example

{
"operationId": "5f2e1a9c3b7d4e6f8a0b1c2d3e4f5a6b",
"status": "SUCCEEDED",
"operationType": "CREATE",
"statusMessage": "Order created successfully.",
"resultHref": "https://api.example.com/orders/64f1b2e3c4d5e6f7a8b9c0d1",
"createdAt": "2026-07-01T12:00:00Z",
"updatedAt": "2026-07-01T12:03:00Z",
"expiresAt": "2026-08-01T12:00:00Z"
}

Motivation and Strategic Goals

A single, enforceable LRO contract lets us auto-generate declarative tooling (Terraform, Kubernetes operators, SDKs) for asynchronous work instead of hand-writing endpoint-specific polling logic, structured status and errors also make long-running failures debuggable for customers, support, and agents rather than hidden behind loosely defined state fields.