crud — entity lifecycle API
cyoda-go version 0.8.3
crud — entity create, read, update, delete, and transition REST API.
SYNOPSIS
Section titled “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}/changesGET /api/entity/{entityId}/transitionsGET /api/entity/statsGET /api/entity/stats/statesGET /api/entity/stats/{entityName}/{modelVersion}GET /api/entity/stats/states/{entityName}/{modelVersion}POST /api/entity/stats/{entityName}/{modelVersion}/queryGET /api/platform-api/entity/fetch/transitionsContext path prefix is CYODA_CONTEXT_PATH (default /api). All endpoints require Authorization: Bearer <token> except when CYODA_IAM_MODE=mock.
DESCRIPTION
Section titled “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
Section titled “ENDPOINTS”POST /api/entity/{format}/{entityName}/{modelVersion} — Create a single entity
format(path):JSONorXMLentityName(path): string — model namemodelVersion(path): int32transactionWindow(query, optional): int32, default100, max1000— applies only when the request body is a JSON array. Maximum entities per transactional batch. Values outside (0, 1000] are rejected with400 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.waitForConsistencyAfter(query, optional): boolean, defaultfalse— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.transactionTimeoutMillis(query, optional): int64, default10000— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.
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:
[{ "transactionId": "cb91fa80-d4a8-11ee-a357-ae468cd3ed16", "entityIds": ["74807f00-ed0d-11ee-a357-ae468cd3ed16"]}]POST /api/entity/{format} — Create a collection (mixed models)
format(path):JSONorXMLtransactionWindow(query, optional): int32, default100, max1000— maximum entities per transactional batch. Values outside (0, 1000] are rejected with400 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, default10000— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.waitForConsistencyAfter(query, optional): boolean, defaultfalse— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.
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:
[ { "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:
[{ "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.
[ { "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 stringpointInTime(query, optional): RFC 3339 date-time — load entity state at this instanttransactionId(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:
{ "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):JSONorXMLentityId(path): UUIDIf-Match(header, optional): transaction ID of last read — optimistic concurrency; if the entity was modified since, returns412 Precondition FailedtransactionTimeoutMillis(query, optional): int64, default10000— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.waitForConsistencyAfter(query, optional): boolean, defaultfalse— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.
Request body: updated entity JSON/XML payload.
Response: 200 OK, application/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):JSONorXMLentityId(path): UUIDtransition(path): string — transition name defined in the model’s workflowIf-Match(header, optional): transaction IDtransactionTimeoutMillis(query, optional): int64, default10000— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.waitForConsistencyAfter(query, optional): boolean, defaultfalse— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.
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, default100, max1000— maximum entities per transactional batch. Values outside (0, 1000] are rejected with400 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, default10000— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.waitForConsistencyAfter(query, optional): boolean, defaultfalse— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.
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:
[ { "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 whenifMatchis supplied): the item surfaces infailed[]; siblings still commit; the chunk’stransactionIdis reported. Per-item ENTITY_MODIFIED conflicts surface inside the200response body via thefailed[]array, not as a4xxenvelope. Inspectfailed[]to detect them. The4xxenvelope 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+json4xx 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:
[ { "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):JSONonly — Merge Patch is JSON-only by RFC 7386;XML⇒415 Unsupported Media TypeentityId(path): UUIDContent-Type(header, required):application/merge-patch+json(RFC 7386 merge patch, implemented) orapplication/json-patch+json(RFC 6902, returns501 Not Implemented); any other value ⇒415 Unsupported Media TypeIf-Match(header, required in some form): see the three-state list belowtransactionTimeoutMillis(query, optional): int64, default10000— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.waitForConsistencyAfter(query, optional): boolean, defaultfalse— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.
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
nullvalue 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’stransactionIdmust still match; returns412 Precondition Failedif 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 as409(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):JSONonlyentityId(path): UUIDtransition(path): string — transition name defined in the model’s workflowContent-Type(header, required): same two-value list as the loopback formIf-Match(header, required in some form): same three-state list as the loopback formtransactionTimeoutMillis(query, optional): int64, default10000— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.waitForConsistencyAfter(query, optional): boolean, defaultfalse— accepted for Cyoda Cloud parity; parsed but currently has no behavioural effect in cyoda-go.
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:
-
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, usePUT(which may extend the schema), then PATCH thereafter. -
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
PUTplus a named transition.
Partial-update error codes:
404 Not Found— entity does not exist409 Conflict(retryable) — a concurrent writer committed between the in-transaction base read and this save (read-set conflict at commit); may occur even withIf-Match: *; caller may retry412 Precondition Failed—If-Match: "<transactionId>"supplied and the storedtransactionIddiffers; seeerrors.ENTITY_MODIFIED415 Unsupported Media Type—XMLformat, or aContent-Typeother than the two patch media types428 Precondition Required— noIf-Matchheader present501 Not Implemented—Content-Type: application/json-patch+json(RFC 6902 is recognised but not yet implemented)- Standard
4xxdomain 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:
{ "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): stringmodelVersion(path): int32transactionSize(query, optional): int32, default1000— maximum entities to delete per transactionpointInTime(query, optional): RFC 3339 — select entities for deletion as at this instantverbose(query, optional): boolean, defaultfalse— whentrue, the responseidsarray lists every deleted entity ID; for a delete-all (empty body)idsis always empty
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:
{ "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): stringmodelVersion(path): int32pageSize(query, optional): int32, default20pageNumber(query, optional): int32, default0pointInTime(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.
GET /api/entity/{entityId}/changes — Get entity change history metadata
entityId(path): UUIDpointInTime(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):
[ { "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, orDELETEtransactionId: present only whenhasEntityis true (i.e., entity payload exists at that version)
GET /api/entity/{entityId}/transitions — List available transitions for an entity
entityId(path): UUIDpointInTime(query, optional): RFC 3339transactionId(query, optional): UUID — derive point-in-time from transaction submit time
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).
GET /api/platform-api/entity/fetch/transitions — List available transitions (platform-api format)
entityClass(query, required): string inName.Versionformat, e.g.,Offer.1entityId(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:
[ { "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:
[ { "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): stringmodelVersion(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): stringmodelVersion(path): int32states(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): stringmodelVersion(path): int32
Request body: application/json. Body size limit: 10 MiB (shared with /search/*).
{ "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. Order in the request determines order in the response’sgroupKeyarray. Duplicate entries (after normalization) → 400DUPLICATE_GROUP_BY. Array projections ([*],[0]) → 400INVALID_GROUP_BY_PATH.condition(optional): the existing searchConditionDSL (SimpleCondition, LifecycleCondition, GroupCondition withAND/OR, ArrayCondition, FunctionCondition). Omitted → match-all. See thesearchtopic for the full DSL.aggregations(optional, 0..N): per entry,op∈ {sum,avg,min,max,stdev};fieldis a scalar JSONPath into the entity payload; optionalasalias for the response key. Whenasis omitted the server synthesizes<op>_<field>with the leading$.stripped from the field (for example,field: "$.costPrice"→ aliassum_costPrice). The server dedupes identical(op, field)pairs. Two aliases colliding on distinct(op, field)pairs → 400DUPLICATE_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→ 400INVALID_LIMIT. Default = unlimited (up to the cardinality ceiling).
Response: 200 OK, application/json, array of GroupedStatsBucket:
[ { "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 restrictions. Every JSONPath in groupBy and aggregation field is scalar-only. Bracket-quoted property access ($['my.field']) is accepted. Array projections ($.items[*], $.items[0]) are rejected at validation time with 400 INVALID_GROUP_BY_PATH (for groupBy) or 400 INVALID_AGGREGATION_FIELD (for aggregations). The reserved token "state" is accepted in groupBy only; it has no leading $. and refers to lifecycle state.
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 withinfloat8range → parsedfloat8. - Strict-numeric string that overflows
float8(for example1e500) → NULL (the value would beInfinity; stripped by an outerNULLIF). 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 the SPI Iterable interface. The native GroupedAggregator pushdown is skipped in this case to preserve read-your-writes semantics. Per backend:
- memory —
Iteratecaptures a snapshot under the read lock, overlaystx.Buffer(buffered saves), and maskstx.Deletes(buffered deletes). The iteration runs lock-free. RYW-correct. - sqlite — the iterator dispatches by
(in-tx, point-in-time). Non-tx, non-PIT queries the liveentitiestable directly withplanQueryWHERE-pushdown (no snapshot involved). Non-tx withpointInTimequeriesentity_versionswithsubmit_time <= pointInTimeto read the historical snapshot. In-tx, non-PIT materializes via the samegetAllTxoverlay thatGetAlluses inside a tx (entity_versions snapshot attx.SnapshotTime, plustx.Bufferoverlay, minustx.Deletes), then iterates the slice; RYW-correct, with buffered writes visible and buffered deletes hidden. In-tx withpointInTimefalls through to the plain PIT path — readsentity_versionsat 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-downGroupedAggregatequery (againstentities) is skipped in-tx by the SPI dispatcher so the service falls through to the streaming tally overIterate, which now honours RYW. - postgres —
Iterateselects from the bi-temporalentity_versionstable withvalid_time <= tx.SnapshotTime AND transaction_time <= CURRENT_TIMESTAMP, and adds(doc->'_meta'->>'deleted')::boolean IS NOT TRUEto skip deletion-marker versions. TheGroupedAggregatepushdown 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.
Backend capability. The endpoint requires the storage backend to implement at least one of the optional SPI interfaces Iterable or GroupedAggregator. The three plugins shipped in this repository (memory, sqlite, postgres) implement both. Backends that implement neither return 501 NOT_IMPLEMENTED_BY_BACKEND.
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_idxon(tenant_id, model_name, model_version, (doc->'_meta'->>'state')) WHERE NOT deleted. Queries grouping by or filtering onstateuse this index without operator action. -
In-tx and
pointInTimepaths are not covered. Those read fromentity_versions, notentities. The state index does not apply. Add expression indexes onentity_versionsif perf demands. -
Hot data-field dimensions (the dimensions that appear most often in
groupByandcondition) are caller responsibility on the non-tx path. Example forvariantId:CREATE INDEX entities_variantid_idxON 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:
CREATE INDEX entities_variantid_idxON 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 tenantMALFORMED_REQUEST—400— JSON parse failedMISSING_GROUP_BY—400—groupByempty or missingINVALID_GROUP_BY_PATH—400— empty entry, or array projection in agroupByJSONPathDUPLICATE_GROUP_BY—400— duplicate entries after normalizationINVALID_AGGREGATION_OP—400—opoutside the set {sum,avg,min,max,stdev}INVALID_AGGREGATION_FIELD—400— aggregationfieldempty or contains array projectionDUPLICATE_AGGREGATION_ALIAS—400— two aliases collide on distinct(op, field)pairsINVALID_OPERATOR—400—conditionoperator outside the canonical list (propagated from search validator)INVALID_CONDITION—400—conditionmalformed or unknowntype(propagated from search validator)INVALID_FIELD_PATH—400—conditionJSONPath absent from the locked schema (propagated from search validator)CONDITION_TYPE_MISMATCH—400—conditionvalue type incompatible with the locked DataType (propagated from search validator)INVALID_POINT_IN_TIME—400—pointInTimenot parseable as RFC 3339INVALID_LIMIT—400—limitnon-positive or> CYODA_STATS_GROUP_MAXGROUP_CARDINALITY_EXCEEDED—422— result buckets would exceedCYODA_STATS_GROUP_MAXNOT_IMPLEMENTED_BY_BACKEND—501— backend implements neitherIterablenorGroupedAggregator- 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
Section titled “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
Section titled “ENTITY ENVELOPE”All entity read operations return entities in the standard envelope:
{ "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 withjson.Numberfor numeric precision)meta.id— UUID stringmeta.modelKey— object withname(string) andversion(int32) identifying the model; present on all entity reads (single-get, list, search).meta.state— current workflow state stringmeta.creationDate— RFC 3339 with nanosecondsmeta.lastUpdateTime— RFC 3339 with nanoseconds; equalscreationDateif never updatedmeta.pointInTime— the as-at point-in-time for which the entity was retrieved, when suppliedmeta.transactionId— present when a transaction ID existsmeta.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
Section titled “OPTIMISTIC CONCURRENCY”Entity writes are protected by two independent guards:
- 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(seeerrors.CONFLICT). On the postgres backend, PostgreSQL’s own SQLSTATE 40001 detection (underREPEATABLE READ) covers write-write races equivalently. Callers do not opt in to this; it cannot be disabled. - Cross-request precondition (opt-in via
If-Match): theIf-Matchheader carries themeta.transactionIdfrom the caller’s earlier read in a separate HTTP request. If the entity’s currentmeta.transactionIddoes not match, the server returns412 Precondition Failed(seeerrors.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
Section titled “ERRORS”errors.ENTITY_NOT_FOUND—404— entity UUID does not existerrors.ENTITY_MODIFIED—412—If-Match-guarded update rejected; supplied transaction ID does not match the entity’s current versionerrors.MODEL_NOT_FOUND—404— model referenced during create does not existerrors.MODEL_NOT_LOCKED—409— model exists but is not inLOCKEDstate; entities cannot be created until the model is lockederrors.VALIDATION_FAILED—400— payload fails schema validation against the modelerrors.INCOMPATIBLE_TYPE—400— entity payload’s leaf value type is not assignable to the schema’s declared DataType for that field; carriesfieldPath,expectedType,actualTypeinpropertieserrors.CONFLICT—409— storage-level transaction serialization conflict (retryable)errors.IDEMPOTENCY_CONFLICT—409— reserved; not yet implemented. Future contract: returned on collection create/update when theIdempotency-Keyheader is re-used with a different payload bodyerrors.UNIQUE_VIOLATION—409— a declared composite unique key already holds this field-value combinationerrors.INVALID_UNIQUE_KEY—422— a unique-key field is null, missing, or has an out-of-range valueerrors.TRANSITION_NOT_FOUND—404— named transition does not exist in the workflowerrors.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_FOUNDwhen the model is not registered for the calling tenant;400for validation failures (MALFORMED_REQUEST,MISSING_GROUP_BY,INVALID_GROUP_BY_PATH,DUPLICATE_GROUP_BY,INVALID_AGGREGATION_OP,INVALID_AGGREGATION_FIELD,DUPLICATE_AGGREGATION_ALIAS,INVALID_POINT_IN_TIME,INVALID_LIMIT);400propagated from the search-condition validator (INVALID_OPERATOR,INVALID_CONDITION,INVALID_FIELD_PATH,CONDITION_TYPE_MISMATCH);422 GROUP_CARDINALITY_EXCEEDEDwhen distinct buckets would exceedCYODA_STATS_GROUP_MAX;501 NOT_IMPLEMENTED_BY_BACKENDwhen the storage backend implements neitherIterablenorGroupedAggregator. The full enumeration with descriptions is in the grouped-stats endpoint section above.
EXAMPLES
Section titled “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 deleted 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
Section titled “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
Section titled “See also”cyoda 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— Search operates against a specific entity model(entityName, modelVersion). Two modes are supported:cyoda 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-levelcriterionfield evaluated at entity creation time. When nocriterionmatches, the engine uses the default built-in workflow.cyoda 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— When an entity update request carries anIf-Matchheader, the server requires the supplied transaction ID to equal the entity’s currentmeta.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— 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— Entity creation and bulk write operations require the model to be in theLOCKEDlifecycle state. Models inDRAFTor unlocked-for-editing state reject writes to prevent schema changes from affecting in-flight data.cyoda help errors VALIDATION_FAILED— UnlikeBAD_REQUEST(which covers parse failures), this error is returned when the payload is parseable but violates the registered model schema — for example, a required field is missing, a value is out of the allowed range, or a workflow guard condition is not satisfied. The error detail includes the specific validation failure.cyoda help errors INCOMPATIBLE_TYPE— Returned byPOST /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 anINTEGERfield, or13.111against anINTEGERfield on a model whosechangeLevelis empty so type widening is not in scope).cyoda 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— The idempotency key is supplied via theIdempotency-KeyHTTP header on collection create and update requests. Seecrudfor the request shape.cyoda 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 or when the entity is in a terminal state that allows no further transitions.cyoda 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— 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— 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— cyoda-go generates its OpenAPI 3.1 specification from the embeddedapi/openapi.yamlfile compiled into the binary at build time. The spec is served at/openapi.jsonwith runtime-patched server URLs. The Scalar API Reference UI is served at/docsand loads the spec from/openapi.json.
Raw formats
Section titled “Raw formats”/help/crud.json— full descriptor (matchesGET /help/{topic}envelope)/help/crud.md— body only