{
  "topic": "search",
  "path": [
    "search"
  ],
  "title": "search — entity search API",
  "synopsis": "Search operates against a specific entity model `(entityName, modelVersion)`. Two modes are supported:",
  "body": "# search\n\n## NAME\n\nsearch — entity search API: synchronous direct search, asynchronous snapshot search, and entity statistics.\n\n## SYNOPSIS\n\n```\nPOST   /api/search/direct/{entityName}/{modelVersion}\nPOST   /api/search/async/{entityName}/{modelVersion}\nGET    /api/search/async/{jobId}\nGET    /api/search/async/{jobId}/status\nPUT    /api/search/async/{jobId}/cancel\n```\n\nContext path prefix is `CYODA_CONTEXT_PATH` (default `/api`). All endpoints require `Authorization: Bearer <token>` except when `CYODA_IAM_MODE=mock`.\n\n## DESCRIPTION\n\nSearch operates against a specific entity model `(entityName, modelVersion)`. Two modes are supported:\n\n**Synchronous (direct) search**: `POST /search/direct/{entityName}/{modelVersion}`. Executes inline within the HTTP request. The response is an NDJSON stream (`application/x-ndjson`), one entity envelope per line. The default result limit is 1000 entities per request; the maximum is 10000 (values above 10000 are clamped to 10000).\n\n**Asynchronous search**: `POST /search/async/{entityName}/{modelVersion}`. Submits a search job and returns a job UUID immediately. The search executes in a background goroutine (or in the plugin's own executor for `SelfExecutingSearchStore` plugins). Results are retrieved by polling status and then fetching pages.\n\nBoth modes accept the same `Condition` DSL as the request body. When the storage plugin implements `spi.Searcher`, the condition is translated to a plugin-level predicate and pushed down to the backend. When translation fails (unsupported condition type) or an active transaction is present, the service falls back to in-memory filtering after a full `GetAll` scan.\n\n## CONDITION DSL\n\nAll search requests accept a `Condition` JSON document as the POST body. Conditions are parsed recursively up to a maximum nesting depth of 50. Body size limit: 10 MiB.\n\n**SimpleCondition** — match a single JSON path against a scalar value:\n\n```json\n{\n  \"type\": \"simple\",\n  \"jsonPath\": \"$.category\",\n  \"operatorType\": \"EQUALS\",\n  \"value\": \"physics\"\n}\n```\n\n- `type`: `\"simple\"`\n- `jsonPath`: JSONPath string (e.g., `\"$.year\"`, `\"$.laureates[0].firstname\"`)\n- `operatorType` (also accepted as `operator` or `operation`): operator string (see valid values below)\n- `value`: any JSON scalar\n\n**Valid `operatorType` values** (exhaustive):\n- `EQUALS` — exact equality; numeric-aware (JSON number vs string representation)\n- `NOT_EQUAL` — inequality; inverse of EQUALS\n- `GREATER_THAN` — numeric or lexicographic greater-than\n- `LESS_THAN` — numeric or lexicographic less-than\n- `GREATER_OR_EQUAL` — greater-than or equal\n- `LESS_OR_EQUAL` — less-than or equal\n- `CONTAINS` — substring or array-element containment\n- `NOT_CONTAINS` — inverse of CONTAINS\n- `STARTS_WITH` — string prefix match\n- `NOT_STARTS_WITH` — inverse of STARTS_WITH\n- `ENDS_WITH` — string suffix match\n- `NOT_ENDS_WITH` — inverse of ENDS_WITH\n- `LIKE` — SQL-style LIKE pattern (`%` = any sequence, `_` = any single char)\n- `IS_NULL` — field is absent or JSON null\n- `NOT_NULL` — field is present and not JSON null\n- `BETWEEN` — range check (exclusive bounds); `value` must be a two-element array `[low, high]`\n- `BETWEEN_INCLUSIVE` — range check (inclusive bounds); same `value` shape as BETWEEN\n- `MATCHES_PATTERN` — regular expression match\n- `IEQUALS` — case-insensitive EQUALS\n- `INOT_EQUAL` — case-insensitive NOT_EQUAL\n- `ICONTAINS` — case-insensitive CONTAINS\n- `INOT_CONTAINS` — case-insensitive NOT CONTAINS\n- `ISTARTS_WITH` — case-insensitive STARTS_WITH\n- `INOT_STARTS_WITH` — case-insensitive NOT STARTS_WITH\n- `IENDS_WITH` — case-insensitive ENDS_WITH\n- `INOT_ENDS_WITH` — case-insensitive NOT ENDS_WITH\n\nOperator strings outside this list are rejected with `errors.BAD_REQUEST` at request time; the error detail includes the canonical list.\n\n**LifecycleCondition** — match entity lifecycle metadata:\n\n```json\n{\n  \"type\": \"lifecycle\",\n  \"field\": \"state\",\n  \"operatorType\": \"EQUALS\",\n  \"value\": \"APPROVED\"\n}\n```\n\n- `type`: `\"lifecycle\"`\n- `field`: `\"state\"`, `\"creationDate\"`, or `\"previousTransition\"`\n- `operatorType` (also accepted as `operator` or `operation`): operator string — same valid values as for `SimpleCondition`\n- `value`: any JSON scalar\n\n**GroupCondition** — combine conditions with a logical operator:\n\n```json\n{\n  \"type\": \"group\",\n  \"operator\": \"AND\",\n  \"conditions\": [\n    { \"type\": \"simple\", \"jsonPath\": \"$.year\", \"operatorType\": \"EQUALS\", \"value\": \"2024\" },\n    { \"type\": \"lifecycle\", \"field\": \"state\", \"operatorType\": \"EQUALS\", \"value\": \"NEW\" }\n  ]\n}\n```\n\n- `type`: `\"group\"`\n- `operator`: `\"AND\"` or `\"OR\"` — these are the only supported values; any other string produces `errors.BAD_REQUEST` at match time (\"unknown group operator\")\n- `conditions`: array of `Condition` objects (recursive; maximum nesting depth 50)\n\n`\"NOT\"` is not supported. An `AND` group with an empty `conditions` array evaluates to `true` (vacuous conjunction). An `OR` group with an empty `conditions` array evaluates to `false` (vacuous disjunction).\n\n**EMPTY CONDITION**: Submitting an empty body (`{}`) or a body with no `type` field as the top-level search condition is rejected with `errors.BAD_REQUEST` — the parser requires a valid `type` field. Submitting a valid `AND` group with an empty `conditions` array (`{\"type\":\"group\",\"operator\":\"AND\",\"conditions\":[]}`) is accepted and matches all entities — this is the correct way to retrieve all entities without filtering.\n\n**ArrayCondition** — match positional values in a JSON array:\n\n```json\n{\n  \"type\": \"array\",\n  \"jsonPath\": \"$.laureates\",\n  \"values\": [\"John\", null, \"Hopfield\"]\n}\n```\n\n- `type`: `\"array\"`\n- `jsonPath`: path to the array field\n- `values`: positional values; `null` entries match any value at that index\n\n**FunctionCondition** — server-side function predicate dispatched to a compute member:\n\n```json\n{\n  \"type\": \"function\",\n  \"function\": {\n    \"name\": \"my-criteria-fn\",\n    \"config\": {\n      \"calculationNodesTags\": \"approval-service\",\n      \"attachEntity\": true,\n      \"responseTimeoutMs\": 30000\n    }\n  }\n}\n```\n\n- `type`: `\"function\"`\n- `function.name`: string — identifies the function; becomes `criteriaId` / `criteriaName` in the dispatch request; required for routing\n- `function.config.calculationNodesTags`: string — comma-separated tags used to select a registered compute member; follows the same tag-intersection rules as processor dispatch\n- `function.config.attachEntity`: boolean (optional, default `true`) — when `true`, the full entity payload is included in the dispatch request\n- `function.config.responseTimeoutMs`: int64 (optional, default `30000`) — timeout in milliseconds\n\nThe function is dispatched as `EntityCriteriaCalculationRequest` to the matching compute member — see the `grpc` topic for the request/response shape. `FunctionCondition` cannot be translated to a storage-plugin pushdown filter; it always executes as a post-filter with in-memory entity loading.\n\n## ENDPOINTS\n\n**POST /api/search/direct/{entityName}/{modelVersion}** — Synchronous search\n\n- `entityName` (path): string\n- `modelVersion` (path): int32\n- `pointInTime` (query, optional): RFC 3339 date-time — search against entity state at this instant\n- `limit` (query, optional): string-encoded integer, clamped to maximum 10000; default 1000\n\nRequest body: `Condition` JSON document.\n\nResponse: `200 OK`, `Content-Type: application/x-ndjson`.\n\nEach line is a complete entity envelope JSON object:\n\n```\n{\"type\":\"ENTITY\",\"data\":{\"category\":\"physics\",\"year\":\"2024\"},\"meta\":{\"id\":\"74807f00-ed0d-11ee-a357-ae468cd3ed16\",\"state\":\"NEW\",\"creationDate\":\"2025-08-01T10:00:00.000000000Z\",\"lastUpdateTime\":\"2025-08-01T10:00:00.000000000Z\"}}\n{\"type\":\"ENTITY\",\"data\":{\"category\":\"chemistry\",\"year\":\"2023\"},\"meta\":{\"id\":\"89abc100-ed0d-11ee-a357-ae468cd3ed16\",\"state\":\"APPROVED\",\"creationDate\":\"2025-07-15T09:00:00.000000000Z\",\"lastUpdateTime\":\"2025-07-20T14:00:00.000000000Z\"}}\n```\n\nThe stream is truncated on encode failure after the header has been sent; the client detects truncation via a connection error or incomplete last line.\n\n**POST /api/search/async/{entityName}/{modelVersion}** — Submit async search job\n\n- `entityName` (path): string\n- `modelVersion` (path): int32\n- `pointInTime` (query, optional): RFC 3339 — if not provided, the current time is captured at submission\n\nRequest body: `Condition` JSON document.\n\nResponse: `200 OK`, `application/json` — bare UUID string (job ID):\n\n```\n\"a1b2c3d4-e5f6-11ee-9e63-ae468cd3ed16\"\n```\n\nThe job is stored with status `RUNNING`. For non-`SelfExecutingSearchStore` backends, a goroutine begins the search immediately using a background context derived from the submitting user's tenant context.\n\n**GET /api/search/async/{jobId}/status** — Get async job status\n\n- `jobId` (path): UUID\n\nResponse: `200 OK`, `application/json`:\n\n```json\n{\n  \"searchJobStatus\": \"SUCCESSFUL\",\n  \"createTime\": \"2025-08-01T10:00:00.000000000Z\",\n  \"entitiesCount\": 42,\n  \"calculationTimeMillis\": 145,\n  \"finishTime\": \"2025-08-01T10:00:00.145000000Z\",\n  \"expirationDate\": \"2025-08-02T10:00:00.000000000Z\"\n}\n```\n\n- `searchJobStatus`: `\"RUNNING\"`, `\"SUCCESSFUL\"`, `\"FAILED\"`, or `\"CANCELLED\"`\n- `createTime`: RFC 3339 with nanoseconds\n- `entitiesCount`: total matching entities (0 while running)\n- `calculationTimeMillis`: elapsed search time in milliseconds\n- `finishTime`: RFC 3339 with nanoseconds; absent when status is `RUNNING`\n- `expirationDate`: `createTime + 24h` — job results expire after this time\n\n**GET /api/search/async/{jobId}** — Retrieve async job results (paginated)\n\n- `jobId` (path): UUID\n- `pageSize` (query, optional): string-encoded integer, default `1000`\n- `pageNumber` (query, optional): string-encoded integer, default `0`; offset = `pageNumber * pageSize`\n\nThe job must be in `SUCCESSFUL` status. Returns `400 BAD_REQUEST` if the job is not yet complete.\n\nResponse: `200 OK`, `application/json`:\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"ENTITY\",\n      \"data\": { \"category\": \"physics\", \"year\": \"2024\" },\n      \"meta\": {\n        \"id\": \"74807f00-ed0d-11ee-a357-ae468cd3ed16\",\n        \"state\": \"NEW\",\n        \"creationDate\": \"2025-08-01T10:00:00.000000000Z\",\n        \"lastUpdateTime\": \"2025-08-01T10:00:00.000000000Z\"\n      }\n    }\n  ],\n  \"page\": {\n    \"number\": 0,\n    \"size\": 1000,\n    \"totalElements\": 42,\n    \"totalPages\": 1\n  }\n}\n```\n\nResults are fetched from the stored entity snapshots at the job's `pointInTime`. Entities deleted or modified after submission are returned as they existed at submission time.\n\n**PUT /api/search/async/{jobId}/cancel** — Cancel a running async job\n\n- `jobId` (path): UUID\n\nCancellation succeeds only when the job status is `RUNNING`. If the job has already reached a terminal state (`SUCCESSFUL`, `FAILED`, or `CANCELLED`), the server returns `400 Bad Request`:\n\n```json\n{\n  \"detail\": \"snapshot by id=<jobId> is not running. current status=SUCCESSFUL\",\n  \"properties\": {\n    \"currentStatus\": \"SUCCESSFUL\",\n    \"snapshotId\": \"<jobId>\"\n  },\n  \"status\": 400,\n  \"title\": \"Bad Request\",\n  \"type\": \"about:blank\"\n}\n```\n\nOn successful cancellation, response: `200 OK`, `application/json`:\n\n```json\n{\n  \"isCancelled\": true,\n  \"cancelled\": true,\n  \"currentSearchJobStatus\": \"CANCELLED\"\n}\n```\n\n## PAGINATION\n\nAsync search results use page-number pagination: `pageNumber=0` is the first page, `offset = pageNumber * pageSize`. `pageNumber` and `pageSize` are both string-encoded integers in query parameters.\n\nSynchronous search does not paginate; use the `limit` parameter (max 10000) to bound results. For large datasets, use async search with page retrieval.\n\n## ERRORS\n\n- `errors.SEARCH_JOB_NOT_FOUND` — `404` — async job UUID does not exist.\n- `errors.SEARCH_JOB_ALREADY_TERMINAL` — `400` — cancel attempted on a job that is already `SUCCESSFUL`, `FAILED`, or `CANCELLED`; error code in response is `BAD_REQUEST`\n- `errors.SEARCH_RESULT_LIMIT` — result set exceeds configured limit\n- `errors.SEARCH_SHARD_TIMEOUT` — per-shard search timeout exceeded (relevant for distributed backends)\n- `errors.BAD_REQUEST` — `400` — malformed condition JSON, invalid limit/pageSize/pageNumber, result retrieval on non-SUCCESSFUL job, unknown async job ID in result retrieval\n\n## EXAMPLES\n\n**Synchronous search — match by field value:**\n\n```\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"simple\",\"jsonPath\":\"$.category\",\"operatorType\":\"EQUALS\",\"value\":\"physics\"}' \\\n  \"http://localhost:8080/api/search/direct/nobel-prize/1\"\n```\n\n**Synchronous search — match by lifecycle state:**\n\n```\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"lifecycle\",\"field\":\"state\",\"operatorType\":\"EQUALS\",\"value\":\"APPROVED\"}' \\\n  \"http://localhost:8080/api/search/direct/nobel-prize/1\"\n```\n\n**Synchronous search — AND group:**\n\n```\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"type\": \"group\",\n    \"operator\": \"AND\",\n    \"conditions\": [\n      {\"type\":\"simple\",\"jsonPath\":\"$.year\",\"operatorType\":\"EQUALS\",\"value\":\"2024\"},\n      {\"type\":\"lifecycle\",\"field\":\"state\",\"operatorType\":\"EQUALS\",\"value\":\"NEW\"}\n    ]\n  }' \\\n  \"http://localhost:8080/api/search/direct/nobel-prize/1\"\n```\n\n**Synchronous search at point in time with limit:**\n\n```\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"group\",\"operator\":\"AND\",\"conditions\":[]}' \\\n  \"http://localhost:8080/api/search/direct/nobel-prize/1?pointInTime=2025-08-01T00:00:00Z&limit=100\"\n```\n\n**Submit async search:**\n\n```\nJOB_ID=$(curl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"simple\",\"jsonPath\":\"$.year\",\"operatorType\":\"EQUALS\",\"value\":\"2024\"}' \\\n  \"http://localhost:8080/api/search/async/nobel-prize/1\" | tr -d '\"')\n```\n\n**Poll async job status:**\n\n```\ncurl -s -H \"Authorization: Bearer $TOKEN\" \\\n  \"http://localhost:8080/api/search/async/$JOB_ID/status\"\n```\n\n**Retrieve async results (page 0):**\n\n```\ncurl -s -H \"Authorization: Bearer $TOKEN\" \\\n  \"http://localhost:8080/api/search/async/$JOB_ID?pageNumber=0&pageSize=500\"\n```\n\n**Cancel an async job:**\n\n```\ncurl -s -X PUT \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  \"http://localhost:8080/api/search/async/$JOB_ID/cancel\"\n```\n\n## SEE ALSO\n\n- crud\n- models\n- analytics\n- errors.SEARCH_JOB_NOT_FOUND\n- errors.SEARCH_JOB_ALREADY_TERMINAL\n- errors.SEARCH_RESULT_LIMIT\n- errors.SEARCH_SHARD_TIMEOUT\n- openapi\n",
  "sections": [
    {
      "name": "NAME",
      "body": "search — entity search API: synchronous direct search, asynchronous snapshot search, and entity statistics."
    },
    {
      "name": "SYNOPSIS",
      "body": "```\nPOST   /api/search/direct/{entityName}/{modelVersion}\nPOST   /api/search/async/{entityName}/{modelVersion}\nGET    /api/search/async/{jobId}\nGET    /api/search/async/{jobId}/status\nPUT    /api/search/async/{jobId}/cancel\n```\n\nContext path prefix is `CYODA_CONTEXT_PATH` (default `/api`). All endpoints require `Authorization: Bearer <token>` except when `CYODA_IAM_MODE=mock`."
    },
    {
      "name": "DESCRIPTION",
      "body": "Search operates against a specific entity model `(entityName, modelVersion)`. Two modes are supported:\n\n**Synchronous (direct) search**: `POST /search/direct/{entityName}/{modelVersion}`. Executes inline within the HTTP request. The response is an NDJSON stream (`application/x-ndjson`), one entity envelope per line. The default result limit is 1000 entities per request; the maximum is 10000 (values above 10000 are clamped to 10000).\n\n**Asynchronous search**: `POST /search/async/{entityName}/{modelVersion}`. Submits a search job and returns a job UUID immediately. The search executes in a background goroutine (or in the plugin's own executor for `SelfExecutingSearchStore` plugins). Results are retrieved by polling status and then fetching pages.\n\nBoth modes accept the same `Condition` DSL as the request body. When the storage plugin implements `spi.Searcher`, the condition is translated to a plugin-level predicate and pushed down to the backend. When translation fails (unsupported condition type) or an active transaction is present, the service falls back to in-memory filtering after a full `GetAll` scan."
    },
    {
      "name": "CONDITION DSL",
      "body": "All search requests accept a `Condition` JSON document as the POST body. Conditions are parsed recursively up to a maximum nesting depth of 50. Body size limit: 10 MiB.\n\n**SimpleCondition** — match a single JSON path against a scalar value:\n\n```json\n{\n  \"type\": \"simple\",\n  \"jsonPath\": \"$.category\",\n  \"operatorType\": \"EQUALS\",\n  \"value\": \"physics\"\n}\n```\n\n- `type`: `\"simple\"`\n- `jsonPath`: JSONPath string (e.g., `\"$.year\"`, `\"$.laureates[0].firstname\"`)\n- `operatorType` (also accepted as `operator` or `operation`): operator string (see valid values below)\n- `value`: any JSON scalar\n\n**Valid `operatorType` values** (exhaustive):\n- `EQUALS` — exact equality; numeric-aware (JSON number vs string representation)\n- `NOT_EQUAL` — inequality; inverse of EQUALS\n- `GREATER_THAN` — numeric or lexicographic greater-than\n- `LESS_THAN` — numeric or lexicographic less-than\n- `GREATER_OR_EQUAL` — greater-than or equal\n- `LESS_OR_EQUAL` — less-than or equal\n- `CONTAINS` — substring or array-element containment\n- `NOT_CONTAINS` — inverse of CONTAINS\n- `STARTS_WITH` — string prefix match\n- `NOT_STARTS_WITH` — inverse of STARTS_WITH\n- `ENDS_WITH` — string suffix match\n- `NOT_ENDS_WITH` — inverse of ENDS_WITH\n- `LIKE` — SQL-style LIKE pattern (`%` = any sequence, `_` = any single char)\n- `IS_NULL` — field is absent or JSON null\n- `NOT_NULL` — field is present and not JSON null\n- `BETWEEN` — range check (exclusive bounds); `value` must be a two-element array `[low, high]`\n- `BETWEEN_INCLUSIVE` — range check (inclusive bounds); same `value` shape as BETWEEN\n- `MATCHES_PATTERN` — regular expression match\n- `IEQUALS` — case-insensitive EQUALS\n- `INOT_EQUAL` — case-insensitive NOT_EQUAL\n- `ICONTAINS` — case-insensitive CONTAINS\n- `INOT_CONTAINS` — case-insensitive NOT CONTAINS\n- `ISTARTS_WITH` — case-insensitive STARTS_WITH\n- `INOT_STARTS_WITH` — case-insensitive NOT STARTS_WITH\n- `IENDS_WITH` — case-insensitive ENDS_WITH\n- `INOT_ENDS_WITH` — case-insensitive NOT ENDS_WITH\n\nOperator strings outside this list are rejected with `errors.BAD_REQUEST` at request time; the error detail includes the canonical list.\n\n**LifecycleCondition** — match entity lifecycle metadata:\n\n```json\n{\n  \"type\": \"lifecycle\",\n  \"field\": \"state\",\n  \"operatorType\": \"EQUALS\",\n  \"value\": \"APPROVED\"\n}\n```\n\n- `type`: `\"lifecycle\"`\n- `field`: `\"state\"`, `\"creationDate\"`, or `\"previousTransition\"`\n- `operatorType` (also accepted as `operator` or `operation`): operator string — same valid values as for `SimpleCondition`\n- `value`: any JSON scalar\n\n**GroupCondition** — combine conditions with a logical operator:\n\n```json\n{\n  \"type\": \"group\",\n  \"operator\": \"AND\",\n  \"conditions\": [\n    { \"type\": \"simple\", \"jsonPath\": \"$.year\", \"operatorType\": \"EQUALS\", \"value\": \"2024\" },\n    { \"type\": \"lifecycle\", \"field\": \"state\", \"operatorType\": \"EQUALS\", \"value\": \"NEW\" }\n  ]\n}\n```\n\n- `type`: `\"group\"`\n- `operator`: `\"AND\"` or `\"OR\"` — these are the only supported values; any other string produces `errors.BAD_REQUEST` at match time (\"unknown group operator\")\n- `conditions`: array of `Condition` objects (recursive; maximum nesting depth 50)\n\n`\"NOT\"` is not supported. An `AND` group with an empty `conditions` array evaluates to `true` (vacuous conjunction). An `OR` group with an empty `conditions` array evaluates to `false` (vacuous disjunction).\n\n**EMPTY CONDITION**: Submitting an empty body (`{}`) or a body with no `type` field as the top-level search condition is rejected with `errors.BAD_REQUEST` — the parser requires a valid `type` field. Submitting a valid `AND` group with an empty `conditions` array (`{\"type\":\"group\",\"operator\":\"AND\",\"conditions\":[]}`) is accepted and matches all entities — this is the correct way to retrieve all entities without filtering.\n\n**ArrayCondition** — match positional values in a JSON array:\n\n```json\n{\n  \"type\": \"array\",\n  \"jsonPath\": \"$.laureates\",\n  \"values\": [\"John\", null, \"Hopfield\"]\n}\n```\n\n- `type`: `\"array\"`\n- `jsonPath`: path to the array field\n- `values`: positional values; `null` entries match any value at that index\n\n**FunctionCondition** — server-side function predicate dispatched to a compute member:\n\n```json\n{\n  \"type\": \"function\",\n  \"function\": {\n    \"name\": \"my-criteria-fn\",\n    \"config\": {\n      \"calculationNodesTags\": \"approval-service\",\n      \"attachEntity\": true,\n      \"responseTimeoutMs\": 30000\n    }\n  }\n}\n```\n\n- `type`: `\"function\"`\n- `function.name`: string — identifies the function; becomes `criteriaId` / `criteriaName` in the dispatch request; required for routing\n- `function.config.calculationNodesTags`: string — comma-separated tags used to select a registered compute member; follows the same tag-intersection rules as processor dispatch\n- `function.config.attachEntity`: boolean (optional, default `true`) — when `true`, the full entity payload is included in the dispatch request\n- `function.config.responseTimeoutMs`: int64 (optional, default `30000`) — timeout in milliseconds\n\nThe function is dispatched as `EntityCriteriaCalculationRequest` to the matching compute member — see the `grpc` topic for the request/response shape. `FunctionCondition` cannot be translated to a storage-plugin pushdown filter; it always executes as a post-filter with in-memory entity loading."
    },
    {
      "name": "ENDPOINTS",
      "body": "**POST /api/search/direct/{entityName}/{modelVersion}** — Synchronous search\n\n- `entityName` (path): string\n- `modelVersion` (path): int32\n- `pointInTime` (query, optional): RFC 3339 date-time — search against entity state at this instant\n- `limit` (query, optional): string-encoded integer, clamped to maximum 10000; default 1000\n\nRequest body: `Condition` JSON document.\n\nResponse: `200 OK`, `Content-Type: application/x-ndjson`.\n\nEach line is a complete entity envelope JSON object:\n\n```\n{\"type\":\"ENTITY\",\"data\":{\"category\":\"physics\",\"year\":\"2024\"},\"meta\":{\"id\":\"74807f00-ed0d-11ee-a357-ae468cd3ed16\",\"state\":\"NEW\",\"creationDate\":\"2025-08-01T10:00:00.000000000Z\",\"lastUpdateTime\":\"2025-08-01T10:00:00.000000000Z\"}}\n{\"type\":\"ENTITY\",\"data\":{\"category\":\"chemistry\",\"year\":\"2023\"},\"meta\":{\"id\":\"89abc100-ed0d-11ee-a357-ae468cd3ed16\",\"state\":\"APPROVED\",\"creationDate\":\"2025-07-15T09:00:00.000000000Z\",\"lastUpdateTime\":\"2025-07-20T14:00:00.000000000Z\"}}\n```\n\nThe stream is truncated on encode failure after the header has been sent; the client detects truncation via a connection error or incomplete last line.\n\n**POST /api/search/async/{entityName}/{modelVersion}** — Submit async search job\n\n- `entityName` (path): string\n- `modelVersion` (path): int32\n- `pointInTime` (query, optional): RFC 3339 — if not provided, the current time is captured at submission\n\nRequest body: `Condition` JSON document.\n\nResponse: `200 OK`, `application/json` — bare UUID string (job ID):\n\n```\n\"a1b2c3d4-e5f6-11ee-9e63-ae468cd3ed16\"\n```\n\nThe job is stored with status `RUNNING`. For non-`SelfExecutingSearchStore` backends, a goroutine begins the search immediately using a background context derived from the submitting user's tenant context.\n\n**GET /api/search/async/{jobId}/status** — Get async job status\n\n- `jobId` (path): UUID\n\nResponse: `200 OK`, `application/json`:\n\n```json\n{\n  \"searchJobStatus\": \"SUCCESSFUL\",\n  \"createTime\": \"2025-08-01T10:00:00.000000000Z\",\n  \"entitiesCount\": 42,\n  \"calculationTimeMillis\": 145,\n  \"finishTime\": \"2025-08-01T10:00:00.145000000Z\",\n  \"expirationDate\": \"2025-08-02T10:00:00.000000000Z\"\n}\n```\n\n- `searchJobStatus`: `\"RUNNING\"`, `\"SUCCESSFUL\"`, `\"FAILED\"`, or `\"CANCELLED\"`\n- `createTime`: RFC 3339 with nanoseconds\n- `entitiesCount`: total matching entities (0 while running)\n- `calculationTimeMillis`: elapsed search time in milliseconds\n- `finishTime`: RFC 3339 with nanoseconds; absent when status is `RUNNING`\n- `expirationDate`: `createTime + 24h` — job results expire after this time\n\n**GET /api/search/async/{jobId}** — Retrieve async job results (paginated)\n\n- `jobId` (path): UUID\n- `pageSize` (query, optional): string-encoded integer, default `1000`\n- `pageNumber` (query, optional): string-encoded integer, default `0`; offset = `pageNumber * pageSize`\n\nThe job must be in `SUCCESSFUL` status. Returns `400 BAD_REQUEST` if the job is not yet complete.\n\nResponse: `200 OK`, `application/json`:\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"ENTITY\",\n      \"data\": { \"category\": \"physics\", \"year\": \"2024\" },\n      \"meta\": {\n        \"id\": \"74807f00-ed0d-11ee-a357-ae468cd3ed16\",\n        \"state\": \"NEW\",\n        \"creationDate\": \"2025-08-01T10:00:00.000000000Z\",\n        \"lastUpdateTime\": \"2025-08-01T10:00:00.000000000Z\"\n      }\n    }\n  ],\n  \"page\": {\n    \"number\": 0,\n    \"size\": 1000,\n    \"totalElements\": 42,\n    \"totalPages\": 1\n  }\n}\n```\n\nResults are fetched from the stored entity snapshots at the job's `pointInTime`. Entities deleted or modified after submission are returned as they existed at submission time.\n\n**PUT /api/search/async/{jobId}/cancel** — Cancel a running async job\n\n- `jobId` (path): UUID\n\nCancellation succeeds only when the job status is `RUNNING`. If the job has already reached a terminal state (`SUCCESSFUL`, `FAILED`, or `CANCELLED`), the server returns `400 Bad Request`:\n\n```json\n{\n  \"detail\": \"snapshot by id=<jobId> is not running. current status=SUCCESSFUL\",\n  \"properties\": {\n    \"currentStatus\": \"SUCCESSFUL\",\n    \"snapshotId\": \"<jobId>\"\n  },\n  \"status\": 400,\n  \"title\": \"Bad Request\",\n  \"type\": \"about:blank\"\n}\n```\n\nOn successful cancellation, response: `200 OK`, `application/json`:\n\n```json\n{\n  \"isCancelled\": true,\n  \"cancelled\": true,\n  \"currentSearchJobStatus\": \"CANCELLED\"\n}\n```"
    },
    {
      "name": "PAGINATION",
      "body": "Async search results use page-number pagination: `pageNumber=0` is the first page, `offset = pageNumber * pageSize`. `pageNumber` and `pageSize` are both string-encoded integers in query parameters.\n\nSynchronous search does not paginate; use the `limit` parameter (max 10000) to bound results. For large datasets, use async search with page retrieval."
    },
    {
      "name": "ERRORS",
      "body": "- `errors.SEARCH_JOB_NOT_FOUND` — `404` — async job UUID does not exist.\n- `errors.SEARCH_JOB_ALREADY_TERMINAL` — `400` — cancel attempted on a job that is already `SUCCESSFUL`, `FAILED`, or `CANCELLED`; error code in response is `BAD_REQUEST`\n- `errors.SEARCH_RESULT_LIMIT` — result set exceeds configured limit\n- `errors.SEARCH_SHARD_TIMEOUT` — per-shard search timeout exceeded (relevant for distributed backends)\n- `errors.BAD_REQUEST` — `400` — malformed condition JSON, invalid limit/pageSize/pageNumber, result retrieval on non-SUCCESSFUL job, unknown async job ID in result retrieval"
    },
    {
      "name": "EXAMPLES",
      "body": "**Synchronous search — match by field value:**\n\n```\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"simple\",\"jsonPath\":\"$.category\",\"operatorType\":\"EQUALS\",\"value\":\"physics\"}' \\\n  \"http://localhost:8080/api/search/direct/nobel-prize/1\"\n```\n\n**Synchronous search — match by lifecycle state:**\n\n```\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"lifecycle\",\"field\":\"state\",\"operatorType\":\"EQUALS\",\"value\":\"APPROVED\"}' \\\n  \"http://localhost:8080/api/search/direct/nobel-prize/1\"\n```\n\n**Synchronous search — AND group:**\n\n```\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"type\": \"group\",\n    \"operator\": \"AND\",\n    \"conditions\": [\n      {\"type\":\"simple\",\"jsonPath\":\"$.year\",\"operatorType\":\"EQUALS\",\"value\":\"2024\"},\n      {\"type\":\"lifecycle\",\"field\":\"state\",\"operatorType\":\"EQUALS\",\"value\":\"NEW\"}\n    ]\n  }' \\\n  \"http://localhost:8080/api/search/direct/nobel-prize/1\"\n```\n\n**Synchronous search at point in time with limit:**\n\n```\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"group\",\"operator\":\"AND\",\"conditions\":[]}' \\\n  \"http://localhost:8080/api/search/direct/nobel-prize/1?pointInTime=2025-08-01T00:00:00Z&limit=100\"\n```\n\n**Submit async search:**\n\n```\nJOB_ID=$(curl -s -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"simple\",\"jsonPath\":\"$.year\",\"operatorType\":\"EQUALS\",\"value\":\"2024\"}' \\\n  \"http://localhost:8080/api/search/async/nobel-prize/1\" | tr -d '\"')\n```\n\n**Poll async job status:**\n\n```\ncurl -s -H \"Authorization: Bearer $TOKEN\" \\\n  \"http://localhost:8080/api/search/async/$JOB_ID/status\"\n```\n\n**Retrieve async results (page 0):**\n\n```\ncurl -s -H \"Authorization: Bearer $TOKEN\" \\\n  \"http://localhost:8080/api/search/async/$JOB_ID?pageNumber=0&pageSize=500\"\n```\n\n**Cancel an async job:**\n\n```\ncurl -s -X PUT \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  \"http://localhost:8080/api/search/async/$JOB_ID/cancel\"\n```"
    },
    {
      "name": "SEE ALSO",
      "body": "- crud\n- models\n- analytics\n- errors.SEARCH_JOB_NOT_FOUND\n- errors.SEARCH_JOB_ALREADY_TERMINAL\n- errors.SEARCH_RESULT_LIMIT\n- errors.SEARCH_SHARD_TIMEOUT\n- openapi"
    }
  ],
  "see_also": [
    "crud",
    "models",
    "analytics",
    "errors.SEARCH_JOB_NOT_FOUND",
    "errors.SEARCH_JOB_ALREADY_TERMINAL",
    "errors.SEARCH_RESULT_LIMIT",
    "errors.SEARCH_SHARD_TIMEOUT",
    "openapi"
  ],
  "stability": "stable",
  "actions": []
}
