﻿# crud — entity lifecycle API

Entities are instances of models. Each entity has a UUID, a model reference (`entityName`, `modelVersion`), and a lifecycle state managed by the workflow …

<em>cyoda-go version <a href="https://github.com/Cyoda/cyoda-go/releases/tag/v0.8.4">0.8.4</a></em>

# crud

## NAME

crud — entity create, read, update, delete, and transition REST API.

## SYNOPSIS

```
POST   /api/entity/{format}/{entityName}/{modelVersion}
POST   /api/entity/{format}
GET    /api/entity/{entityId}
PUT    /api/entity/{format}/{entityId}
PUT    /api/entity/{format}/{entityId}/{transition}
PUT    /api/entity/{format}
PATCH  /api/entity/{format}/{entityId}
PATCH  /api/entity/{format}/{entityId}/{transition}
DELETE /api/entity/{entityId}
DELETE /api/entity/{entityName}/{modelVersion}
GET    /api/entity/{entityName}/{modelVersion}
GET    /api/entity/{entityId}/changes
GET    /api/entity/{entityId}/transitions
GET    /api/entity/stats
GET    /api/entity/stats/states
GET    /api/entity/stats/{entityName}/{modelVersion}
GET    /api/entity/stats/states/{entityName}/{modelVersion}
POST   /api/entity/stats/{entityName}/{modelVersion}/query
GET    /api/platform-api/entity/fetch/transitions
```

Context path prefix is `CYODA_CONTEXT_PATH` (default `/api`). All endpoints require `Authorization: Bearer <token>` except when `CYODA_IAM_MODE=mock`.

## DESCRIPTION

Entities are instances of models. Each entity has a UUID, a model reference (`entityName`, `modelVersion`), and a lifecycle state managed by the workflow engine. Creating an entity requires the referenced model to be in `LOCKED` state. All write operations run within a Cyoda transaction and return a `transactionId` alongside the affected entity IDs.

Body size limit on all write endpoints: 10 MiB.

## ENDPOINTS

**POST /api/entity/{format}/{entityName}/{modelVersion}** — Create a single entity

- `format` (path): `JSON` or `XML`
- `entityName` (path): string — model name
- `modelVersion` (path): int32
- `transactionWindow` (query, optional): int32, default `100`, max `1000` — applies only when the request body is a JSON array. Maximum entities per transactional batch. Values outside (0, 1000] are rejected with `400 BAD_REQUEST`. Array bodies exceeding the window are split into multiple transactional batches committed sequentially; each chunk is one transaction. The response is then an array with one element per chunk in commit order; chunks committed before any later failure remain durable.
- `transactionTimeoutMillis` (query, optional): int64 — maximum time the server may spend before the first commit. Exceeding it rolls back and fails with `408 errors.TRANSACTION_TIMEOUT`; nothing is committed. On a chunked write, only the first chunk is covered — expiry after that surfaces as a `TRANSACTION_TIMEOUT` element in the per-chunk response, not a 408. Rejected with `400` on a request that joins an open transaction. Absent means no server-side timeout.

If the request body is a JSON array, each element is treated as a separate entity of the same model and the collection-create chunking contract applies (see `transactionWindow` above and the `POST /api/entity/{format}` partial-success shape below).

Response: `200 OK`, `application/json`. Single-object body returns a one-element array; an array body returns one element per committed chunk in commit order:

```json
[{
  "transactionId": "cb91fa80-d4a8-11ee-a357-ae468cd3ed16",
  "entityIds": ["74807f00-ed0d-11ee-a357-ae468cd3ed16"]
}]
```

**POST /api/entity/{format}** — Create a collection (mixed models)

- `format` (path): `JSON` or `XML`
- `transactionWindow` (query, optional): int32, default `100`, max `1000` — maximum entities per transactional batch. Values outside (0, 1000] are rejected with `400 BAD_REQUEST`. Collections exceeding the window are split into multiple transactional batches committed sequentially; each chunk is one transaction. The response is an array with one element per chunk in commit order.
- `transactionTimeoutMillis` (query, optional): int64 — maximum time the server may spend before the first commit. Exceeding it rolls back and fails with `408 errors.TRANSACTION_TIMEOUT`; nothing is committed. On a chunked write, only the first chunk is covered — expiry after that surfaces as a `TRANSACTION_TIMEOUT` element in the per-chunk response, not a 408. Rejected with `400` on a request that joins an open transaction. Absent means no server-side timeout.

**IMPORTANT — `payload` is a JSON-encoded string, not an object.**

The `payload` field must be a string containing the JSON-encoded entity body, not a nested JSON object. This is a deliberate API contract — it preserves the payload as an opaque blob through the pipeline.

Correct: `"payload": "{\"category\":\"physics\"}"`
Wrong:   `"payload": {"category":"physics"}`   (will be rejected with `errors.BAD_REQUEST`)

Request body: JSON array of `CreatePayload` objects:

```json
[
  {
    "model": { "name": "nobel-prize", "version": 1 },
    "payload": "{\"category\":\"physics\",\"year\":\"2024\"}"
  }
]
```

Each item may reference a different model. The collection is committed in transactional batches of at most `transactionWindow` items. Within a single chunk the create is all-or-nothing; chunks committed before any later failure remain durable.

Response: `200 OK`, `application/json`, `EntityTransactionResponse` array — one element per committed chunk in commit order:

```json
[{
  "transactionId": "cb91fa80-d4a8-11ee-a357-ae468cd3ed16",
  "entityIds": [
    "74807f00-ed0d-11ee-a357-ae468cd3ed16",
    "72428380-0704-11ef-a357-ae468cd3ed16"
  ]
}]
```

**Partial-success on chunk failure.** If a later chunk fails after earlier chunks have committed, the response is still HTTP 200 carrying the durable chunks plus an error element marking the failed chunk's index. Subsequent chunks are not attempted.

```json
[
  { "transactionId": "tx-0", "entityIds": ["..."] },
  { "transactionId": "tx-1", "entityIds": ["..."] },
  { "error": { "code": "MODEL_NOT_FOUND", "message": "...", "chunkIndex": 2 } }
]
```

When the very first chunk fails (no durable progress), the response is the standard `application/problem+json` 4xx error envelope instead.

**GET /api/entity/{entityId}** — Read a single entity by UUID

- `entityId` (path): UUID string
- `pointInTime` (query, optional): RFC 3339 date-time — load entity state at this instant
- `transactionId` (query, optional): UUID — load entity state as of the end of this transaction

`pointInTime` and `transactionId` are mutually exclusive; supplying both returns `400 BAD_REQUEST`.

Response: `200 OK`, `application/json`:

```json
{
  "type": "ENTITY",
  "data": { "category": "physics", "year": "2024" },
  "meta": {
    "id": "74807f00-ed0d-11ee-a357-ae468cd3ed16",
    "modelKey": { "name": "nobel-prize", "version": 1 },
    "state": "NEW",
    "creationDate": "2025-08-01T10:00:00Z",
    "lastUpdateTime": "2025-08-01T10:00:00Z",
    "transactionId": "cb91fa80-d4a8-11ee-a357-ae468cd3ed16",
    "transitionForLatestSave": "loopback"
  }
}
```

**PUT /api/entity/{format}/{entityId}** — Update a single entity (loopback transition)

- `format` (path): `JSON` or `XML`
- `entityId` (path): UUID
- `If-Match` (header, optional): transaction ID of last read — optimistic concurrency; if the entity was modified since, returns `412 Precondition Failed`
- `transactionTimeoutMillis` (query, optional): int64 — maximum time the server may spend before the first commit. Exceeding it rolls back and fails with `408 errors.TRANSACTION_TIMEOUT`; nothing is committed. On a chunked write, only the first chunk is covered — expiry after that surfaces as a `TRANSACTION_TIMEOUT` element in the per-chunk response, not a 408. Rejected with `400` on a request that joins an open transaction. Absent means no server-side timeout.

Request body: updated entity JSON/XML payload.

Response: `200 OK`, `application/json`:

```json
{
  "transactionId": "733e7180-c055-11ef-a357-ae468cd3ed16",
  "entityIds": ["cdcff600-bab1-11ee-a357-ae468cd3ed16"]
}
```

**PUT /api/entity/{format}/{entityId}/{transition}** — Update a single entity with a named transition

- `format` (path): `JSON` or `XML`
- `entityId` (path): UUID
- `transition` (path): string — transition name defined in the model's workflow
- `If-Match` (header, optional): transaction ID
- `transactionTimeoutMillis` (query, optional): int64 — maximum time the server may spend before the first commit. Exceeding it rolls back and fails with `408 errors.TRANSACTION_TIMEOUT`; nothing is committed. On a chunked write, only the first chunk is covered — expiry after that surfaces as a `TRANSACTION_TIMEOUT` element in the per-chunk response, not a 408. Rejected with `400` on a request that joins an open transaction. Absent means no server-side timeout.

Response: `200 OK`, same shape as loopback update.

**PUT /api/entity/{format}** — Update a collection (mixed entities)

- `format` (path): `JSON` (only supported format today; single-item PUT endpoints still accept XML)
- `transactionWindow` (query, optional): int32, default `100`, max `1000` — maximum entities per transactional batch. Values outside (0, 1000] are rejected with `400 BAD_REQUEST`. Collections exceeding the window are split into multiple transactional batches committed sequentially; each chunk is one transaction. The response is an array with one element per chunk in commit order; chunks committed before any later failure remain durable.
- `transactionTimeoutMillis` (query, optional): int64 — maximum time the server may spend before the first commit. Exceeding it rolls back and fails with `408 errors.TRANSACTION_TIMEOUT`; nothing is committed. On a chunked write, only the first chunk is covered — expiry after that surfaces as a `TRANSACTION_TIMEOUT` element in the per-chunk response, not a 408. Rejected with `400` on a request that joins an open transaction. Absent means no server-side timeout.

**IMPORTANT — `payload` is a JSON-encoded string, not an object.**

The `payload` field in each update item must be a string containing the JSON-encoded entity body, not a nested JSON object (same contract as collection create).

Correct: `"payload": "{\"category\":\"physics\"}"`
Wrong:   `"payload": {"category":"physics"}`   (will be rejected with `errors.BAD_REQUEST`)

Each item may also carry an optional `ifMatch` field — the entity's last-known `meta.transactionId` from a prior read. Items with `ifMatch` get a per-item cross-request optimistic-concurrency precondition: if the entity has been modified since, the item is rejected with `code=ENTITY_MODIFIED` and surfaces in the chunk's `failed` array, **without rolling the chunk back**. Items in the same chunk without `ifMatch` (or with a still-valid one) commit as usual. This mirrors the `If-Match` header on the single-item PUT endpoints, scoped per-item.

Request body: JSON array of update items:

```json
[
  {
    "id": "8824c480-c166-11ee-9e63-ae468cd3ed16",
    "payload": "{\"category\":\"physics\",\"year\":\"2024\"}",
    "transition": "UPDATE",
    "ifMatch": "733e7180-c055-11ef-a357-ae468cd3ed16"
  }
]
```

Failure handling within a chunk:

- **Per-item `ENTITY_MODIFIED` (only when `ifMatch` is supplied):** the item surfaces in `failed[]`; siblings still commit; the chunk's `transactionId` is reported. Per-item ENTITY_MODIFIED conflicts surface inside the `200` response body via the `failed[]` array, **not** as a `4xx` envelope. Inspect `failed[]` to detect them. The `4xx` envelope is reserved for chunk-wide infrastructure failures.
- **Any other per-item failure** (missing entity, validation, non-conflict engine error): the entire chunk rolls back, matching the pre-existing contract. Earlier chunks remain durable; if the first chunk fails the response is a standard `application/problem+json` 4xx envelope.

Each per-item `ENTITY_MODIFIED` is also reflected in the entity's state-machine audit log: the engine emits a paired `STATE_MACHINE_START` plus `TRANSITION_ABORTED` event (with `data.reason = "ENTITY_MODIFIED"`, `data.expectedTxId`, and `data.actualTxId`) so consumers can correlate the failure cleanly without orphaned start events.

Response: `200 OK`, `application/json`, `EntityTransactionResponse` array — one element per committed chunk in commit order. The optional `failed` array is omitted on chunks with no per-item `ENTITY_MODIFIED` failures:

```json
[
  {
    "transactionId": "733e7180-c055-11ef-a357-ae468cd3ed16",
    "entityIds": ["8824c480-c166-11ee-9e63-ae468cd3ed16"],
    "failed": [
      {
        "entityId": "31134900-d9cb-11ee-9e63-ae468cd3ed16",
        "error": {
          "code": "ENTITY_MODIFIED",
          "message": "entity has been modified since last read",
          "itemIndex": 1
        }
      }
    ]
  }
]
```

`itemIndex` is the failing item's zero-based position within its chunk's request slice (per-chunk relative). When every item in a chunk fails its `ifMatch` precondition, the chunk still commits as a zero-write transaction — `entityIds` is empty, `failed[]` lists every item, and `transactionId` remains meaningful for audit correlation.

**PATCH /api/entity/{format}/{entityId}** — Partial update of a single entity (loopback transition)

- `format` (path): `JSON` only — Merge Patch is JSON-only by RFC 7386; `XML` ⇒ `415 Unsupported Media Type`
- `entityId` (path): UUID
- `Content-Type` (header, required): `application/merge-patch+json` (RFC 7386 merge patch, implemented) or `application/json-patch+json` (RFC 6902, returns `501 Not Implemented`); any other value ⇒ `415 Unsupported Media Type`
- `If-Match` (header, required in some form): see the three-state list below
- `transactionTimeoutMillis` (query, optional): int64 — maximum time the server may spend before the first commit. Exceeding it rolls back and fails with `408 errors.TRANSACTION_TIMEOUT`; nothing is committed. On a chunked write, only the first chunk is covered — expiry after that surfaces as a `TRANSACTION_TIMEOUT` element in the per-chunk response, not a 408. Rejected with `400` on a request that joins an open transaction. Absent means no server-side timeout.

Request body: a sparse JSON object (the patch document). The patch is applied to the **stored** entity payload using RFC 7386 merge semantics:

- A key present in the patch with a non-null value overwrites the target key.
- A key whose value is itself an object merges recursively.
- A key present in the patch with an explicit `null` value deletes that key from the stored payload.
- Arrays are replaced wholesale — there are no element-level operations.
- The empty patch `{}` is a valid no-op merge: the data is unchanged, but the request still commits a new transaction and fires the loopback transition (consistent with PUT-with-empty-body).

**If-Match precondition (required).** Unlike `PUT`, where omitting `If-Match` means unconditional replace, PATCH requires `If-Match` to be present in some form, because the merge is applied relative to a base the caller read; silently patching a stale base risks lost updates. The token is the `meta.transactionId` from the caller's last `GET` of this entity — the same field the existing `PUT` `If-Match` uses.

Three states are accepted:

- `If-Match: "<transactionId>"` — Conditional: the stored entity's `transactionId` must still match; returns `412 Precondition Failed` if it has moved since the caller's read.
- `If-Match: *` — Unconditional opt-out: merge onto current state regardless of version (entity must exist); a concurrent-writer race can still surface as `409` (see below).
- Absent — returns `428 Precondition Required`; the response body explains the two valid choices.

Response: `200 OK`, same shape as the single-item PUT update.

**PATCH /api/entity/{format}/{entityId}/{transition}** — Partial update of a single entity with a named transition

- `format` (path): `JSON` only
- `entityId` (path): UUID
- `transition` (path): string — transition name defined in the model's workflow
- `Content-Type` (header, required): same two-value list as the loopback form
- `If-Match` (header, required in some form): same three-state list as the loopback form
- `transactionTimeoutMillis` (query, optional): int64 — maximum time the server may spend before the first commit. Exceeding it rolls back and fails with `408 errors.TRANSACTION_TIMEOUT`; nothing is committed. On a chunked write, only the first chunk is covered — expiry after that surfaces as a `TRANSACTION_TIMEOUT` element in the per-chunk response, not a 408. Rejected with `400` on a request that joins an open transaction. Absent means no server-side timeout.

The merge patch is applied first; the named transition's processors then run on the merged state and may further mutate the entity. Response: `200 OK`, same shape as the loopback form.

**Two behaviours to be aware of:**

1. **Strict validation.** The merged result is validated strictly against the model schema — the model is never extended, regardless of its `ChangeLevel`. A PATCH cannot introduce a field that the schema does not already allow, even when the model is in an extend-permitting mode. To add a genuinely new field, use `PUT` (which may extend the schema), then PATCH thereafter.

2. **Processors run after the merge.** Under a named transition, the transition's processors run on the merged entity state and may overwrite fields the patch set. The observable result of a patch with a named transition is not guaranteed to retain the patched values when the transition has mutating processors. This is the same ordering as `PUT` plus a named transition.

**Partial-update error codes:**

- `404 Not Found` — entity does not exist
- `409 Conflict` (retryable) — a concurrent writer committed between the in-transaction base read and this save (read-set conflict at commit); may occur even with `If-Match: *`; caller may retry
- `412 Precondition Failed` — `If-Match: "<transactionId>"` supplied and the stored `transactionId` differs; see `errors.ENTITY_MODIFIED`
- `415 Unsupported Media Type` — `XML` format, or a `Content-Type` other than the two patch media types
- `428 Precondition Required` — no `If-Match` header present
- `501 Not Implemented` — `Content-Type: application/json-patch+json` (RFC 6902 is recognised but not yet implemented)
- Standard `4xx` domain errors — the **merged result** fails strict schema validation (full domain detail + error code)

**DELETE /api/entity/{entityId}** — Delete a single entity by UUID

- `entityId` (path): UUID

Response: `200 OK`, `application/json`:

```json
{
  "id": "a2242880-8d30-11ef-9e63-ae468cd3ed16",
  "modelKey": {
    "name": "nobel-prize",
    "version": 4
  },
  "transactionId": "9fe62d00-a727-11ef-9e63-ae468cd3ed16"
}
```

**DELETE /api/entity/{entityName}/{modelVersion}** — Delete entities for a model (conditional)

- `entityName` (path): string
- `modelVersion` (path): int32
- `transactionSize` (query, optional): int32 — when set, matched entities (including a delete-all with no condition) are deleted in version-guarded batches of this size instead of one transaction. Batches already committed stay durable if a later batch fails. A per-id version mismatch (the entity changed after selection) or a batch's commit failure is reported per-id in `deleteResult.idToError`, not retried. Rejected with `400` on a request that joins an open transaction. Absent means a single transaction. Without `pointInTime`, the batched delete re-selects before each batch; if matching entities keep being created it is stopped at its batch cap and fails `409 DELETE_NOT_CONVERGED` (retryable), with the batches already committed left deleted.
- `pointInTime` (query, optional): RFC 3339 — select the entities that existed at this instant (committed state; the ambient transaction is ignored) and delete their current rows. Absent means the current committed state. An entity selected at the instant but already gone is reported in `deleteResult.idToError`.
- `verbose` (query, optional): boolean, default `false` — when `true`, the response `ids` array lists every entity ID the delete attempted (an empty body lists them too); an ID whose delete failed also appears in `deleteResult.idToError`; on a large model this enumerates every entity of the model in one response.

Request body: optional `AbstractConditionDto` (same condition DSL as `/search/*`). When the body is absent or empty, all entities of the model are deleted.

Response: `200 OK`, `application/json`:

```json
{
  "entityModelClassId": "022d9200-0cf0-11ef-9e63-ae468cd3ed16",
  "ids": ["ffef9680-26e6-11ef-9e63-ae468cd3ed16"],
  "deleteResult": {
    "numberOfEntitites": 4,
    "numberOfEntititesRemoved": 3,
    "idToError": {
      "cecbe400-7402-11ef-9e63-ae468cd3ed16": "Some error message"
    }
  }
}
```

`deleteResult.numberOfEntitites` is the count of entities matched by the condition (or total when no condition). `deleteResult.numberOfEntititesRemoved` is the count actually removed (may be lower if individual deletes failed). Returns `400 INVALID_CONDITION` on a malformed condition body.

**GET /api/entity/{entityName}/{modelVersion}** — List all entities for a model (paginated)

- `entityName` (path): string
- `modelVersion` (path): int32
- `pageSize` (query, optional): int32, default `20`
- `pageNumber` (query, optional): int32, default `0`
- `pointInTime` (query, optional): RFC 3339 — return entities as they existed at this instant (as-at, inclusive)

Response: `200 OK`, `application/json`, array of entity envelopes (same shape as single-entity GET). Returns `404 MODEL_NOT_FOUND` when the model is not registered for the calling tenant. Order is stable and deterministic; the specific order is storage-engine-specific (entity-ID based) — see `docs/plugins/*.md` for each backend's canonical order.

**GET /api/entity/{entityId}/changes** — Get entity change history metadata

- `entityId` (path): UUID
- `pointInTime` (query, optional): RFC 3339 — view history as it existed at this time

Response: `200 OK`, `application/json`, array of change entries in reverse-chronological order (newest first):

```json
[
  {
    "changeType": "UPDATE",
    "timeOfChange": "2025-08-02T09:00:00Z",
    "user": "admin",
    "transactionId": "733e7180-c055-11ef-a357-ae468cd3ed16"
  },
  {
    "changeType": "CREATE",
    "timeOfChange": "2025-08-01T10:00:00Z",
    "user": "admin",
    "transactionId": "cb91fa80-d4a8-11ee-a357-ae468cd3ed16"
  }
]
```

- `changeType`: `CREATE`, `UPDATE`, or `DELETE`
- `transactionId`: present only when `hasEntity` is true (i.e., entity payload exists at that version)

**GET /api/entity/{entityId}/transitions** — List available transitions for an entity

- `entityId` (path): UUID
- `pointInTime` (query, optional): RFC 3339
- `transactionId` (query, optional): UUID — derive point-in-time from transaction submit time. The transaction must belong to the caller's tenant; an unknown or foreign transaction ID returns `400 BAD_REQUEST`.

`pointInTime` and `transactionId` are mutually exclusive; supplying both returns `400 BAD_REQUEST`. When neither is provided, the current time is used.

Response: `200 OK`, `application/json`, array of available transition names (as returned by the workflow engine).

The names come from the workflow the entity's criterion selects — the same definition a subsequent transition will run (see `cyoda help workflows`, *Workflow-level selection*). Because selection is evaluated here, this read carries the same failure modes as a write: `400 WORKFLOW_FAILED` when a criterion cannot be evaluated, `503 NO_COMPUTE_MEMBER_FOR_TAG` when the cause is specifically an unavailable compute member for a `function` criterion's tags. The request fails rather than answering from a different workflow. The read records no audit events.

**GET /api/platform-api/entity/fetch/transitions** — List available transitions (platform-api format)

- `entityClass` (query, required): string in `Name.Version` format, e.g., `Offer.1`
- `entityId` (query, required): UUID string

Response: `200 OK`, `application/json`, array of available transition names.

**GET /api/entity/stats** — Entity count statistics across all models

Response: `200 OK`, `application/json`:

```json
[
  { "modelName": "nobel-prize", "modelVersion": 1, "count": 42 },
  { "modelName": "family-member", "modelVersion": 3, "count": 7 }
]
```

**GET /api/entity/stats/states** — Entity count by state across all models

- `states` (query, optional): comma-separated list of state names to filter by; maximum 1000 entries

Response: `200 OK`, `application/json`:

```json
[
  { "modelName": "nobel-prize", "modelVersion": 1, "state": "NEW", "count": 10 },
  { "modelName": "nobel-prize", "modelVersion": 1, "state": "APPROVED", "count": 32 }
]
```

**GET /api/entity/stats/{entityName}/{modelVersion}** — Entity count for a specific model

- `entityName` (path): string
- `modelVersion` (path): int32

Response: `200 OK`, `application/json`, single `ModelStatsDto`. Returns `404 MODEL_NOT_FOUND` when the model is not registered for the calling tenant.

**GET /api/entity/stats/states/{entityName}/{modelVersion}** — Entity count by state for a specific model

- `entityName` (path): string
- `modelVersion` (path): int32
- `states` (query, optional): list of state names to filter by; maximum 1000 entries

Response: `200 OK`, `application/json`, array of `ModelStateStatsDto`. Returns `404 MODEL_NOT_FOUND` when the model is not registered for the calling tenant.

**POST /api/entity/stats/{entityName}/{modelVersion}/query** — Grouped statistics with optional aggregations

Returns aggregate counts (and optional `sum`/`avg`/`min`/`max`/`stdev`) grouped by entity data fields and/or lifecycle state. Restricts the population by an optional `Condition` DSL predicate (same shape as `/search/*` — see the `search` topic). Supports `pointInTime` historical snapshots.

- `entityName` (path): string
- `modelVersion` (path): int32

Request body: `application/json`. Body size limit: 10 MiB (shared with `/search/*`).

```json
{
  "groupBy":      ["$.variantId", "state"],
  "condition":    { "type": "lifecycle", "field": "state", "operatorType": "NOT_EQUAL", "value": "shipped" },
  "aggregations": [
    { "op": "sum",   "field": "$.costPrice", "as": "totalCost" },
    { "op": "avg",   "field": "$.costPrice" },
    { "op": "stdev", "field": "$.costPrice" }
  ],
  "pointInTime":  "2026-06-14T12:00:00Z",
  "limit":        100
}
```

Request fields:

- `groupBy` (required, 1..N entries): each entry is the reserved token `"state"` or a scalar JSONPath (with the required `$.` leader). Order in the request determines order in the response's `groupKey` array. Duplicate entries → 400 `DUPLICATE_GROUP_BY`. A path outside the grammar below → 400 `INVALID_GROUP_BY_PATH`.
- `condition` (optional): the existing search `Condition` DSL (SimpleCondition, LifecycleCondition, GroupCondition with `AND`/`OR`/`NOT`, ArrayCondition). Omitted → match-all. A FunctionCondition at any depth → 400 `INVALID_CONDITION`; it is a criterion shape, not a search shape. See the `search` topic for the full DSL.
- `aggregations` (optional, 0..N): per entry, `op` ∈ {`sum`, `avg`, `min`, `max`, `stdev`}; `field` is a scalar JSONPath into the entity payload; optional `as` alias for the response key. When `as` is omitted the server synthesizes `<op>_<field>` with the leading `$.` stripped from the field (for example, `field: "$.costPrice"` → alias `sum_costPrice`). The server dedupes identical `(op, field)` pairs. Two aliases colliding on distinct `(op, field)` pairs → 400 `DUPLICATE_AGGREGATION_ALIAS`.
- `pointInTime` (optional RFC 3339): historical snapshot; default = now.
- `limit` (optional positive int): top-N. Must be `≤ CYODA_STATS_GROUP_MAX` (default 10000); `> CYODA_STATS_GROUP_MAX` → 400 `INVALID_LIMIT`. Default = unlimited (up to the cardinality ceiling).

Response: `200 OK`, `application/json`, array of `GroupedStatsBucket`:

```json
[
  {
    "groupKey": [
      { "path": "$.variantId", "value": "1111" },
      { "path": "state",       "value": "available" }
    ],
    "count": 812,
    "aggregations": { "totalCost": 41200.00, "avg_costPrice": 50.74, "stdev_costPrice": 18.42 }
  },
  {
    "groupKey": [
      { "path": "$.variantId", "value": null },
      { "path": "state",       "value": "available" }
    ],
    "count": 3,
    "aggregations": { "totalCost": null, "avg_costPrice": null, "stdev_costPrice": null }
  }
]
```

Each bucket's `aggregations` map is keyed by either the explicit `as` alias or the synthesized `<op>_<field>` label. The `aggregations` map is omitted entirely when the request supplied no aggregations.

Sort order is backend-independent: primary key is `count` descending; tiebreaker is `groupKey` lex order (element-wise; `null` sorts before any string; strings compared bytes-wise).

**JSONPath grammar.** Every JSONPath in `groupBy` and aggregation `field` is scalar-only, and is checked at the API boundary — before any storage backend runs — against:

```
path    = "$." segment ( "." segment )*
segment = 1*( ALPHA / DIGIT / "_" / "-" )      ; ASCII only
```

The `$.` leader is **required**: these are JSON Paths, and a bare `variantId` is not one. Everything outside the grammar is rejected with 400 `INVALID_GROUP_BY_PATH` (for `groupBy`) or 400 `INVALID_AGGREGATION_FIELD` (for aggregations): a missing leader, bracket-quoted property access (`$['country']`, `$.['country']` — write `$.country`), array subscripts of any form (`$.items[*]`, `$.items[0]`), recursive descent (`$..name`), filter expressions, leading/trailing/doubled dots, `$` on its own, whitespace, quotes, control bytes, and non-ASCII characters. There is no dotted spelling for an array position either: `$.items.0` is a valid path, but it names the field literally called `0` inside `items` — a field name may be entirely digits — not an array element. An array position is not addressable by `groupBy` or an aggregation `field` at all; group or aggregate on a scalar path instead.

This is the same grammar every storage backend enforces on its own query paths, so a request is accepted or rejected identically whichever execution path it takes. A `condition`'s `jsonPath` obeys a superset of this grammar: it additionally permits array subscripts (`$.items[*]`, `$.items[0]`), which a storage backend pushes down where it can and otherwise evaluates itself — see `cyoda help search`. A path is validated, never rewritten: the response's `groupKey` path echoes exactly what the request sent.

The reserved token `"state"` is accepted in `groupBy` only; it is a token, not a path, so it has no leading `$.` and refers to lifecycle state. There is no defined aggregate over lifecycle state, so `"state"` as an aggregation `field` is just an identifier missing its leader and is rejected.

**Aggregation operators.** Five operators: `sum`, `avg`, `min`, `max`, `stdev`. `stdev` is the sample standard deviation (divisor `n − 1`); when `n < 2`, the value is `null` on both the pushdown and streaming paths. `sum`, `avg`, and `stdev` treat non-numeric and absent field values as NULL (the value is skipped, not zero). `min` and `max` are lexicographic over text values and numeric over numeric values; the comparison ordering matches the underlying backend's collation for the pushed-down path.

**Numeric coercion on postgres.** The postgres backend wraps every numeric aggregation argument in the `cyoda_try_float8(text)` SQL function (shipped as a built-in migration). The behavior is exhaustive:

- Empty string → NULL.
- Strict-numeric string matching `-?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?` and within `float8` range → parsed `float8`.
- Strict-numeric string that overflows `float8` (for example `1e500`) → NULL (the value would be `Infinity`; stripped by an outer `NULLIF`).
- `NaN`, `Infinity`, `-Infinity`, `inf` → NULL (not strict-numeric per the regex above).
- Any other non-numeric string (`"n/a"`, `"unknown"`, etc.) → NULL.

The function is `IMMUTABLE PARALLEL SAFE` (the planner inlines and parallelizes). NULL values are skipped by `SUM`/`AVG`/`STDDEV_SAMP` per standard SQL semantics. A single dirty value does not abort the query.

**Non-scalar runtime values.** When a `groupBy` JSONPath resolves to a JSON object or array at runtime, the bucket key for that dimension is `null`. Numbers and booleans group by their canonical text representation (for example the integer `42` and the string `"42"` both bucket under `"42"`).

**In-transaction behavior.** Calls made under an active transaction (the request carried a transaction context) route through the streaming-tally path via `EntityStore.Iterate`. The native `GroupedAggregator` pushdown is skipped in this case to preserve read-your-writes semantics. Per backend:

- **memory and sqlite** — Inside a transaction, sqlite streams one merged cursor (committed snapshot on the reader connection plus the transaction's own buffered writes, staged deletes suppressed); memory walks a pointer snapshot of the merged view. Neither copies entity payloads beyond the rows it yields, and `trackingRead` records only yielded rows. Non-tx, non-PIT sqlite queries the live `entities` table directly with `planQuery` WHERE-pushdown (no snapshot involved); non-tx with `pointInTime` queries `entity_versions` with `submit_time <= pointInTime` to read the historical snapshot. In-tx with `pointInTime` falls through to the plain PIT path — reads `entity_versions` at the supplied snapshot WITHOUT applying the tx-buffer overlay; PIT is historical-read by definition, so the in-flight buffer is a documented limitation, and the result reflects committed history rather than the caller's uncommitted edits at the requested instant. The fully-pushed-down `GroupedAggregate` query (against `entities`) is skipped in-tx by the SPI dispatcher so the service falls through to the streaming tally over `Iterate`, which now honours RYW.
- **postgres** — `Iterate` selects from the bi-temporal `entity_versions` table with `valid_time <= tx.SnapshotTime AND transaction_time <= CURRENT_TIMESTAMP`, and adds `(doc->'_meta'->>'deleted')::boolean IS NOT TRUE` to skip deletion-marker versions. The `GroupedAggregate` pushdown is skipped in-tx.

**Cardinality ceiling.** `CYODA_STATS_GROUP_MAX` (default 10000) bounds the number of distinct group buckets the endpoint will produce. When the result would exceed the ceiling, the request fails with 422 `GROUP_CARDINALITY_EXCEEDED` (retry with a more selective `condition` or fewer `groupBy` dimensions). The same value caps the request `limit`: `limit > CYODA_STATS_GROUP_MAX` is rejected up-front with 400 `INVALID_LIMIT`.

**Index guidance — postgres.**

- **State grouping/filtering on the non-tx pushdown path is index-backed out of the box.** The shipped migration creates `entities_state_idx` on `(tenant_id, model_name, model_version, (doc->'_meta'->>'state')) WHERE NOT deleted`. Queries grouping by or filtering on `state` use this index without operator action.
- **In-tx and `pointInTime` paths are not covered.** Those read from `entity_versions`, not `entities`. The state index does not apply. Add expression indexes on `entity_versions` if perf demands.
- **Hot data-field dimensions** (the dimensions that appear most often in `groupBy` and `condition`) are caller responsibility on the non-tx path. Example for `variantId`:

  ```sql
  CREATE INDEX entities_variantid_idx
  ON entities (tenant_id, model_name, model_version, (doc->>'variantId'))
  WHERE NOT deleted;
  ```

**Index guidance — sqlite.** The shipped schema indexes `(tenant_id, model_name, model_version)` covering the WHERE-clause prefix. For hot grouping dimensions, add expression indexes on the JSON path:

```sql
CREATE INDEX entities_variantid_idx
ON entities (json_extract(data, '$.variantId'));
```

**Memory backend cost.** The snapshot walk is `O(tenant entities)`, not `O(model entities)` — the underlying map is keyed `tenantID → entityID → versions` and is not partitioned by model. A tenant with many models pays a constant per-request walk cost proportional to all their entities, even when querying one model. Relevant for operators running memory-backed deployments with many models per tenant.

Error codes (response carries RFC 9457 problem+json with `properties.errorCode` set to the machine-readable code below):

- `MODEL_NOT_FOUND` — `404` — model not registered for the calling tenant
- `MALFORMED_REQUEST` — `400` — body unreadable, not valid JSON, carrying an unrecognised top-level field (decoding is strict), or carrying a `pointInTime` that is not RFC 3339
- `MISSING_GROUP_BY` — `400` — `groupBy` empty or missing
- `INVALID_GROUP_BY_PATH` — `400` — `groupBy` JSONPath outside the grammar above (missing `$.` leader, bracket-quoted access, array projection, recursive descent, disallowed character)
- `DUPLICATE_GROUP_BY` — `400` — duplicate `groupBy` entries
- `INVALID_AGGREGATION_OP` — `400` — `op` outside the set {`sum`, `avg`, `min`, `max`, `stdev`}
- `INVALID_AGGREGATION_FIELD` — `400` — aggregation `field` outside the same JSONPath grammar
- `DUPLICATE_AGGREGATION_ALIAS` — `400` — two aliases collide on distinct `(op, field)` pairs
- `INVALID_CONDITION` — `400` — `condition` malformed, unknown `type`, `operatorType` outside the canonical list, malformed `LIKE` or `MATCHES_PATTERN` operand, or bad `BETWEEN` arity (propagated from search validator)
- `INVALID_FIELD_PATH` — `400` — a `condition` `jsonPath` is not valid JSON Path, names an unknown meta field, or is absent from the locked schema (propagated from search validator)
- `CONDITION_TYPE_MISMATCH` — `400` — `condition` value type incompatible with the locked DataType (propagated from search validator)
- `INVALID_LIMIT` — `400` — `limit` non-positive or `> CYODA_STATS_GROUP_MAX`
- `GROUP_CARDINALITY_EXCEEDED` — `422` — result buckets would exceed `CYODA_STATS_GROUP_MAX`
- Standard `401` (missing/invalid Bearer), `403` (authenticated but not authorized), `413` (body exceeds 10 MiB), `500` (internal/driver error with ticket UUID; full detail logged server-side) apply as elsewhere.

## POINT-IN-TIME SEMANTICS

A `pointInTime` read returns entity state **as at exactly that instant,
inclusive**: a version whose write timestamp equals `pointInTime` is included
(`<=`), and no rounding is applied to the requested time. The bound is compared
against stored version timestamps at the storage engine's native precision.

Behaviour is identical across every read path — single-entity read, list,
search, grouped statistics, change history, and available transitions — and
across storage backends. Because backends store timestamps at different
precisions (down to milliseconds on some deployments), cross-backend results are
guaranteed to agree at **millisecond granularity**; finer-grained ordering
within a single millisecond is backend-defined. Timestamps are accepted and
emitted as RFC 3339 with full fractional precision.

## ENTITY ENVELOPE

All entity read operations return entities in the standard envelope:

```json
{
  "type": "ENTITY",
  "data": { ... },
  "meta": {
    "id": "74807f00-ed0d-11ee-a357-ae468cd3ed16",
    "modelKey": { "name": "nobel-prize", "version": 1 },
    "state": "NEW",
    "creationDate": "2025-08-01T10:00:00.000000000Z",
    "lastUpdateTime": "2025-08-01T10:00:00.000000000Z",
    "pointInTime": "2025-08-01T10:00:00Z",
    "transactionId": "cb91fa80-d4a8-11ee-a357-ae468cd3ed16",
    "transitionForLatestSave": "UPDATE"
  }
}
```

- `type` — always `"ENTITY"`
- `data` — the entity's JSON payload (decoded with `json.Number` for numeric precision)
- `meta.id` — UUID string
- `meta.modelKey` — object with `name` (string) and `version` (int32) identifying the model; present on all entity reads (single-get, list, search).
- `meta.state` — current workflow state string
- `meta.creationDate` — RFC 3339 with nanoseconds
- `meta.lastUpdateTime` — RFC 3339 with nanoseconds; equals `creationDate` if never updated
- `meta.pointInTime` — the as-at point-in-time for which the entity was retrieved, when supplied
- `meta.transactionId` — present when a transaction ID exists
- `meta.transitionForLatestSave` — transition name that produced the latest save. Valid values: `"loopback"` (loopback update with no transition supplied by the client) or the named transition string. **Known bug:** the server currently stores the literal `"workflow"` for engine-driven initial-state writes; there is no valid `"workflow"` value and this is tracked for fix.

## OPTIMISTIC CONCURRENCY

Entity writes are protected by two independent guards:

1. **Transaction-level (always on):** every write runs under Snapshot Isolation with first-committer-wins (SI+FCW) read-set validation at commit time. A concurrent committer who changes an entity this transaction read will cause this transaction to abort at commit with `409 CONFLICT, retryable: true` (see `errors.CONFLICT`). On the postgres backend, PostgreSQL's own SQLSTATE 40001 detection (under `REPEATABLE READ`) covers write-write races equivalently. Callers do not opt in to this; it cannot be disabled.
2. **Cross-request precondition (opt-in via `If-Match`):** the `If-Match` header carries the `meta.transactionId` from the caller's earlier read in a separate HTTP request. If the entity's current `meta.transactionId` does not match, the server returns `412 Precondition Failed` (see `errors.ENTITY_MODIFIED`). This catches the race window between the caller's GET and PUT — a window the transaction-level guard cannot see, because the GET and PUT happen in different transactions.

To use the cross-request precondition: read the entity (`GET /entity/{id}`), note `meta.transactionId`, include it in `If-Match` on the subsequent update. Omitting `If-Match` does not turn off transaction-level conflict detection, but it does mean the PUT will reconcile against whatever state the entity has at PUT-time — including any concurrent commits that happened between the caller's GET and PUT.

See `cyoda help errors ENTITY_MODIFIED` for the recovery flow on a `412`.

## ERRORS

- `errors.ENTITY_NOT_FOUND` — `404` — entity UUID does not exist
- `errors.ENTITY_MODIFIED` — `412` — `If-Match`-guarded update rejected; supplied transaction ID does not match the entity's current version
- `errors.MODEL_NOT_FOUND` — `404` — model referenced during create does not exist
- `errors.MODEL_NOT_LOCKED` — `409` — model exists but is not in `LOCKED` state; entities cannot be created until the model is locked
- `errors.VALIDATION_FAILED` — `400` — payload fails schema validation against the model
- `errors.INCOMPATIBLE_TYPE` — `400` — entity payload's leaf value type is not assignable to the schema's declared DataType for that field; carries `fieldPath`, `expectedType`, `actualType` in `properties`
- `errors.CONFLICT` — `409` — storage-level transaction serialization conflict (retryable)
- `errors.IDEMPOTENCY_CONFLICT` — `409` — reserved; not yet implemented. Future contract: returned on collection create/update when the `Idempotency-Key` header is re-used with a different payload body
- `errors.UNIQUE_VIOLATION` — `409` — a declared composite unique key already holds this field-value combination
- `errors.INVALID_UNIQUE_KEY` — `422` — a unique-key field is null, missing, or has an out-of-range value
- `errors.TRANSITION_NOT_FOUND` — `400` — named transition does not exist in the workflow
- `errors.WORKFLOW_FAILED` — `400` — the workflow engine rejected the operation: a transition criterion did not match, a processor failed, the workflow selected for the entity does not declare its current state, or a workflow selection criterion could not be evaluated. Reachable on create, a named transition, a transition-less (loopback) update, and both transitions reads — selection runs on every door
- `errors.NO_COMPUTE_MEMBER_FOR_TAG` — `503` — retryable — a `function` criterion or processor needs a compute member for its tags and none is connected. Reachable on the transitions reads too, since they evaluate workflow selection criteria
- `errors.BAD_REQUEST` — `400` — malformed request, invalid UUID, conflicting query parameters, states filter exceeds 1000 entries
- Grouped-stats query (`POST /api/entity/stats/{entityName}/{modelVersion}/query`) — `404 MODEL_NOT_FOUND` when the model is not registered for the calling tenant; `400` for validation failures (`MALFORMED_REQUEST`, `MISSING_GROUP_BY`, `INVALID_GROUP_BY_PATH`, `DUPLICATE_GROUP_BY`, `INVALID_AGGREGATION_OP`, `INVALID_AGGREGATION_FIELD`, `DUPLICATE_AGGREGATION_ALIAS`, `INVALID_LIMIT`); `400` propagated from the search-condition validator (`INVALID_CONDITION`, `INVALID_FIELD_PATH`, `CONDITION_TYPE_MISMATCH`); `422 GROUP_CARDINALITY_EXCEEDED` when distinct buckets would exceed `CYODA_STATS_GROUP_MAX`. The full enumeration with descriptions is in the grouped-stats endpoint section above.

## EXAMPLES

**Create a single entity:**

```
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"category":"physics","year":"2024"}' \
  "http://localhost:8080/api/entity/JSON/nobel-prize/1"
```

**Read an entity:**

```
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/entity/74807f00-ed0d-11ee-a357-ae468cd3ed16"
```

**Read an entity at a point in time:**

```
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/entity/74807f00-ed0d-11ee-a357-ae468cd3ed16?pointInTime=2025-08-01T10:00:00Z"
```

**Update an entity with loopback transition:**

```
curl -s -X PUT \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: cb91fa80-d4a8-11ee-a357-ae468cd3ed16" \
  -d '{"category":"chemistry","year":"2024"}' \
  "http://localhost:8080/api/entity/JSON/74807f00-ed0d-11ee-a357-ae468cd3ed16"
```

**Update an entity with a named transition:**

```
curl -s -X PUT \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"category":"chemistry","year":"2024"}' \
  "http://localhost:8080/api/entity/JSON/74807f00-ed0d-11ee-a357-ae468cd3ed16/APPROVE"
```

**Delete a single entity:**

```
curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/entity/74807f00-ed0d-11ee-a357-ae468cd3ed16"
```

**Delete entities for a model (all, or filtered by condition):**

```
# Delete all entities for the model:
curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/entity/nobel-prize/1"

# Delete only VALIDATED entities and list the attempted IDs:
curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"lifecycle","field":"state","operatorType":"EQUALS","value":"VALIDATED"}' \
  "http://localhost:8080/api/entity/nobel-prize/1?verbose=true"
```

**List all entities for a model (page 0, size 20):**

```
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/entity/nobel-prize/1?pageSize=20&pageNumber=0"
```

**Get entity change history:**

```
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/entity/74807f00-ed0d-11ee-a357-ae468cd3ed16/changes"
```

**Get available transitions:**

```
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/entity/74807f00-ed0d-11ee-a357-ae468cd3ed16/transitions"
```

**Create a multi-model collection:**

```
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '[{"model":{"name":"nobel-prize","version":1},"payload":"{\"category\":\"physics\",\"year\":\"2024\"}"}]' \
  "http://localhost:8080/api/entity/JSON"
```

**Get statistics by state for a model:**

```
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/entity/stats/states/nobel-prize/1"
```

**Grouped statistics — count by state and country, with sum/avg aggregations, top 5000:**

```
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "groupBy": ["state", "$.country"],
    "condition": {"type":"simple","jsonPath":"$.year","operatorType":"GREATER_OR_EQUAL","value":2000},
    "aggregations": [
      {"op":"sum","field":"$.amount","as":"totalAmount"},
      {"op":"avg","field":"$.amount"}
    ],
    "limit": 5000
  }' \
  "http://localhost:8080/api/entity/stats/nobel-prize/1/query"
```

**Grouped statistics — count-only (no aggregations), at a historical point in time:**

```
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "groupBy": ["$.variantId"],
    "pointInTime": "2026-01-01T00:00:00Z"
  }' \
  "http://localhost:8080/api/entity/stats/nobel-prize/1/query"
```

## SEE ALSO

- models
- search
- workflows
- errors.ENTITY_NOT_FOUND
- errors.ENTITY_MODIFIED
- errors.MODEL_NOT_FOUND
- errors.MODEL_NOT_LOCKED
- errors.VALIDATION_FAILED
- errors.INCOMPATIBLE_TYPE
- errors.CONFLICT
- errors.UNIQUE_VIOLATION
- errors.INVALID_UNIQUE_KEY
- errors.TRANSITION_NOT_FOUND
- messages
- openapi

## See also

- [`cyoda help models`](/help/models/) — A model is a named, versioned schema registered per tenant. Every entity in the system is an instance of exactly one model. Models are identified by `(entityName, modelVersion)`. The model ID is a deterministic UUID v5 derived from that key: `UUID.newSHA1(NameSpaceURL, "{entityName}.{modelVersion}")`.
- [`cyoda help search`](/help/search/) — Search operates against a specific entity model `(entityName, modelVersion)`. Two modes are supported:
- [`cyoda help workflows`](/help/workflows/) — A workflow definition is a named finite state machine attached to an entity model. Workflows are stored per model reference `(entityName, modelVersion)`. A model may have multiple workflow definitions; the engine selects the matching one per entity using the workflow-level `criterion` field evaluated at entity creation time. When no `criterion` matches, the engine uses the default built-in workflow.
- [`cyoda help errors ENTITY_NOT_FOUND`](/help/errors/entity_not_found/) — No entity with the given ID exists in the tenant's data store, or the entity existed at a point-in-time that precedes the requested snapshot. Also returned for audit log lookups when the specified event or message cannot be found.
- [`cyoda help errors ENTITY_MODIFIED`](/help/errors/entity_modified/) — When an entity update request carries an `If-Match` header, the server requires the supplied transaction ID to equal the entity's current `meta.transactionId`. A mismatch means another writer has updated the entity since the caller's last read. The optimistic-concurrency guard rejects the update rather than silently overwrite.
- [`cyoda help errors MODEL_NOT_FOUND`](/help/errors/model_not_found/) — The entity type or model name specified in the request does not exist in the tenant's model registry. Occurs on write paths (creating entities with an unknown type, importing data that references a missing model, performing model lifecycle transitions on a model ID that does not exist) and on read paths (list, stats, grouped-stats, and search operations that reference an unregistered model).
- [`cyoda help errors MODEL_NOT_LOCKED`](/help/errors/model_not_locked/) — Entity creation and bulk write operations require the model to be in the `LOCKED` lifecycle state. Models in `DRAFT` or unlocked-for-editing state reject writes to prevent schema changes from affecting in-flight data.
- [`cyoda help errors VALIDATION_FAILED`](/help/errors/validation_failed/) — Unlike `BAD_REQUEST` (which covers parse failures, bad parameters, and unstorable bytes), this error is returned when the payload parsed and then failed against the registered model. On an entity write that means:
- [`cyoda help errors INCOMPATIBLE_TYPE`](/help/errors/incompatible_type/) — Returned by `POST /entity/{format}/{name}/{version}` and the entity-update surfaces when a leaf field's value cannot be coerced into the model's declared DataType (e.g. submitting `"abc"` against an `INTEGER` field, or `13.111` against an `INTEGER` field on a model whose `changeLevel` is empty so type widening is not in scope).
- [`cyoda help errors CONFLICT`](/help/errors/conflict/) — The server detected that the entity was modified by another writer between the time it was read and the time the current write was committed. Normal outcome under concurrent load.
- [`cyoda help errors IDEMPOTENCY_CONFLICT`](/help/errors/idempotency_conflict/) — The idempotency key is supplied via the `Idempotency-Key` HTTP header on collection create and update requests. See `crud` for the request shape.
- [`cyoda help errors TRANSACTION_TIMEOUT`](/help/errors/transaction_timeout/) — Fires when the client-supplied `transactionTimeoutMillis` elapses before the first commit. Nothing was committed.
- [`cyoda help errors TRANSITION_NOT_FOUND`](/help/errors/transition_not_found/) — Entity workflow state machines define explicit transitions between states. This error fires when a transition is triggered that does not exist in the model's workflow definition for the entity's current state. Also occurs when the transition name is misspelled, when the transition is disabled, when it is a scheduled transition (those fire on their timer and are never manually fireable), or when the entity is in a terminal state that allows no further transitions.
- [`cyoda help errors WORKFLOW_FAILED`](/help/errors/workflow_failed/) — During an entity create or transition operation the associated workflow processors (pre-processors, post-processors) or guard conditions ran but one of them signalled failure. The failure message from the processor is included in the error detail.
- [`cyoda help errors NO_COMPUTE_MEMBER_FOR_TAG`](/help/errors/no_compute_member_for_tag/) — Workflow processors are dispatched to nodes that advertise matching compute tags. When no node with the required tag is alive in the cluster within the configured wait timeout (`CYODA_DISPATCH_WAIT_TIMEOUT`), the operation is rejected with this error.
- [`cyoda help errors UNIQUE_VIOLATION`](/help/errors/unique_violation/) — The entity payload contains field values that collide with an existing entity's composite unique key. Unlike an optimistic-concurrency CONFLICT (which is retryable), a unique-key violation is a permanent data constraint — retrying the same payload without changing the key field values will produce the same result.
- [`cyoda help errors INVALID_UNIQUE_KEY`](/help/errors/invalid_unique_key/) — A composite unique key requires every declared field to be present and non-null. This error is returned when the entity payload is missing a value for at least one key field, or the value cannot be normalized to a valid claim (for example, a NaN or ±Infinity for a numeric key field).
- [`cyoda help messages`](/help/messages/) — An edge message is an arbitrary JSON payload stored under a server-generated time-UUID together with a fixed set of AMQP-aligned headers and an optional flat metadata map. The store is standalone: a message is not an entity, and creating one does not touch the workflow engine — no transition fires, no processor or criterion runs. Edge messaging is a durable, tenant-scoped staging buffer at the platform edge, a peer of the entity store rather than part of it.
- [`cyoda help openapi`](/help/openapi/) — cyoda-go generates its OpenAPI 3.1 specification from the embedded `api/openapi.yaml` file compiled into the binary at build time. The spec is served at `/openapi.json` with runtime-patched server URLs. The Scalar API Reference UI is served at `/docs` and loads the spec from `/openapi.json`.

## Raw formats

- [`/help/crud.json`](/help/crud.json) — full descriptor (matches `GET /help/{topic}` envelope)
- [`/help/crud.md`](/help/crud.md) — body only