Lab
Lab is the analytical engine over your passport data. It runs PassQL — read queries and bulk transforms — against a Postgres read model of your passports. Queries never touch the passport store, so they consume no read capacity; transforms recompute their values from live passports and write them back, with a preview first, a per-property change ledger, and a one-step rollback.
Lab is opt-in per team, in the app or with POST /v1/lab/enable; until it is on, every other Lab endpoint returns 409 lab_not_enabled.
Read operations require lab:read. Transforms, rollbacks, and enabling or disabling Lab require lab:write — and like every ability, a key's is capped by what the person who minted it may currently do on the team, so a key whose owner's role does not grant lab:write (the default Editor role does not) is refused with 403 insufficient_permissions. A transform or rollback that assigns publish_status additionally requires releases:write, because a release is what the team is billed for. Transforms and rollbacks also need your team on the deployment's transform allowlist, otherwise 403 transforms_not_enabled.
Most write bodies take an ast — the compiled PassQL query. You rarely hand-write one: build it in the visual Blocks builder, or write PassQL text and parse it into an AST. The examples below use the AST parse emits.
The query result model
The result of running a query. Every query is recorded as a run, cached or not, and every result carries a watermark — the "data as of" freshness marker for the read model.
- Name
object- Type
- string
- Description
Always
lab.query_result.
- Name
run_id- Type
- integer
- Description
The
lab.runthis query was recorded as (e.g.4821). Read it back with retrieve a run.
- Name
mode- Type
- string
- Description
rowsfor a row-per-passport result,groupsfor an aggregated one. Decided by the AST.
- Name
columns- Type
- array
- Description
Column descriptors in display order, each a
{key, label, kind}object.keyis what the row objects are keyed by;labelis for headings and is not stable enough to match on.
- Name
rows- Type
- array
- Description
The result rows, each an object keyed by the
keyof the corresponding column.
- Name
next_cursor- Type
- string
- Description
Keyset cursor for the next page of a
rows-mode result;nullingroupsmode and on the last page. Send it back asast.cursor— see running a query. Nullable.
- Name
duration_ms- Type
- integer
- Description
How long the query took to execute, in milliseconds. Near zero when
cachedis true.
- Name
cached- Type
- boolean
- Description
Whether the rows came from the auto-invalidating query cache rather than a fresh execution.
- Name
watermark- Type
- string
- Description
ISO 8601 timestamp of how far the read model has caught up. Results reflect writes up to this instant.
The run model
Every query, transform, rollback, backfill, and export is recorded as a run. Transforms and rollbacks are asynchronous: execute hands you a run id and you poll retrieve a run until status is terminal (complete, partial, or failed).
- Name
object- Type
- string
- Description
Always
lab.run.
- Name
id- Type
- integer
- Description
The run's id — a plain integer (e.g.
4822), not a prefixed string. Lab runs are the API's only integer-keyed resource; an id that is not a positive integer is a404, not a400.
- Name
type- Type
- string
- Description
query,transform,rollback,backfill, orexport.
- Name
status- Type
- string
- Description
pendinguntil a worker picks the run up,runningwhile workers fan out, thencomplete,partial(finished with failures or conflicts) orfailed.
- Name
total_count- Type
- integer
- Description
Passports the run intends to touch. It is accumulated as enumeration discovers them, so it can still be climbing while
statusisrunning. Nullable.
- Name
processed_count- Type
- integer
- Description
Passports processed so far. Nullable.
- Name
failed_count- Type
- integer
- Description
Passports the run could not apply. Nullable.
- Name
conflict_count- Type
- integer
- Description
Passports left alone rather than overwritten — their value moved under the run, or a release rule refused the write. Read them back with
status=conflicton the run's changes. Nullable.
- Name
result_count- Type
- integer
- Description
Rows a query returned.
nullon runs that write rather than read.
- Name
duration_ms- Type
- integer
- Description
Execution time in milliseconds, recorded when the run finishes. Nullable.
- Name
dsl_text- Type
- string
- Description
The PassQL source the run was submitted with, kept verbatim for display.
nullon a run driven by a raw AST. Nullable.
- Name
rolls_back_run_id- Type
- integer
- Description
For a rollback run, the transform run it reverses. Nullable.
- Name
rolled_back_by_run_id- Type
- integer
- Description
For a transform run, the rollback run that reverted it. A run can be rolled back at most once. Nullable.
- Name
rollback_eligible- Type
- boolean
- Description
Whether rolling this run back would be accepted. True for at most one run per team — the most recent finished, not-yet-rolled-back transform — so it flips to false as soon as another transform completes.
- Name
cache_hit- Type
- boolean
- Description
Whether a query run was served from the cache.
- Name
created_at- Type
- string
- Description
ISO 8601 UTC timestamp of creation.
- Name
updated_at- Type
- string
- Description
ISO 8601 UTC timestamp of the last update.
- Name
finished_at- Type
- string
- Description
ISO 8601 UTC timestamp of when the run reached a terminal status.
nullwhile it is pending or running.
Run a query
Executes a PassQL read query over the Lab read model and returns a columns/rows result with a freshness watermark. Send the compiled ast; optionally include the dsl_text it came from, which is stored on the resulting run for display. Requires lab:read.
A query that fails validation or compilation returns 422 with the error envelope, whose error.errors names the offending AST stages. A query naming an ontology that is not your team's is 403 forbidden_ontology; one that exceeds the statement timeout is 408 query_timeout — narrow the filter, reduce the page size, or use an export.
Required attributes
- Name
ast- Type
- object
- Description
The compiled PassQL query AST. Obtain one from
POST /v1/lab/dsl/parse, or let the Blocks builder emit it.
Optional attributes
- Name
dsl_text- Type
- string
- Description
The PassQL source the AST came from. Stored on the run so the query reads back in the UI; ignored for execution.
Paging a query
Row-mode results page inside the AST, not by query parameter: ast.limit is the page size and ast.cursor takes the previous response's next_cursor. Keep going until next_cursor is null. The PassQL grammar has no cursor clause, so add the field to the parsed AST yourself.
This is a different mechanism from the limit/cursor pagination the list endpoints use: a Lab query cursor is a plain keyset token rather than an encrypted, scope-bound one, and a malformed or stale one fails with 422 bad_cursor rather than 400 invalid_cursor.
Translated values
Text-bearing properties hold a value per language, and a property reference reads exactly one of them. With nothing specified it is each ontology's own default language, which is what an existing query has always matched. from … lang:"de" moves the whole pipeline to German, and p"Description"@"de" pins one reference, overriding the pipeline. A passport with no translation in the requested language reads as empty for that reference — there is no fallback. In the AST these are source.lang and a reference's prop.lang, each one of the 24 official EU language codes; any other value fails validation naming that path. See PassQL.
Request
curl https://api.synexcloud.com/v1/lab/query \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ast": {
"v": 1,
"kind": "query",
"source": { "ontologies": ["ontology_7d1f4a2b9c3e5081"], "depths": ["B"] },
"filter": { "cond": { "prop": { "name": "Capacity" }, "op": "gt", "value": 70 } },
"select": [{ "dim": "friendly_id" }, { "prop": { "name": "Capacity" } }],
"limit": 100
},
"dsl_text": "from ontology:\"EV Battery Pack\" depth:B | where p\"Capacity\" > 70 | select friendly_id, p\"Capacity\" | limit 100"
}'
Response
{
"object": "lab.query_result",
"run_id": 4821,
"mode": "rows",
"columns": [
{ "key": "c0", "label": "friendly_id", "kind": "dim" },
{ "key": "c1", "label": "Capacity", "kind": "prop" }
],
"rows": [
{ "c0": "PACK-0001", "c1": 78.4 },
{ "c0": "PACK-0002", "c1": 82.1 }
],
"next_cursor": "eyJvIjpudWxsLCJzIjoicGFzc185YzRlN2ExZDViM2YyMDY0In0",
"duration_ms": 47,
"cached": false,
"watermark": "2026-07-22T14:10:41Z"
}
Validate a query
Compile-checks an ast without executing it — the fast way to confirm a query is well formed before you run or preview it. Requires lab:read.
A valid AST answers {"valid": true, "mode": …} with the mode it would produce. A failure is the standard error envelope at whatever status the underlying failure carries — 422 with the offending stages in error.errors for an invalid or uncompilable AST, but 403 forbidden_ontology when the query names another team's ontology. Branch on error.code, not on the status.
Required attributes
- Name
ast- Type
- object
- Description
The PassQL AST to compile-check.
Request
curl https://api.synexcloud.com/v1/lab/query/validate \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ast": {
"v": 1,
"kind": "query",
"source": { "ontologies": ["ontology_7d1f4a2b9c3e5081"], "depths": ["B"] },
"filter": { "cond": { "prop": { "name": "Capacity" }, "op": "gt", "value": 70 } },
"select": [{ "dim": "friendly_id" }, { "prop": { "name": "Capacity" } }],
"limit": 100
}
}'
Response
{
"valid": true,
"mode": "rows"
}
Failure
{
"error": {
"type": "invalid_request_error",
"code": "invalid_ast",
"message": "The query is invalid.",
"param": null,
"errors": [
{ "path": "select", "message": "Unknown selector \"friendly\"." }
],
"request_id": "req_0a9f3c2b1e4d7f0a"
}
}
Parse PassQL to an AST
Parses PassQL source text into the ast the other endpoints consume, resolving ontology names in the from clause to ontology ids along the way. This is the usual way to obtain an AST outside the Blocks builder: write PassQL, parse it, then hand the AST to run, validate, or preview. Requires lab:read.
Invalid input returns 422 with error.param set to dsl_text and one of four codes: query_empty (blank dsl_text), parse_error (unparseable PassQL), unknown_ontology (no ontology of that name in the team) or ambiguous_ontology (the name matches more than one — errors[].expected lists the candidate ids). Each row of error.errors carries a message, a byte offset, a length, and what was expected; offset and length are null for an ontology name that cannot be resolved, which is not tied to a token position.
Required attributes
- Name
dsl_text- Type
- string
- Description
The PassQL source to parse.
Request
curl https://api.synexcloud.com/v1/lab/dsl/parse \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "dsl_text": "from ontology:\"EV Battery Pack\" depth:B | where p\"Capacity\" > 70 | select friendly_id, p\"Capacity\" | limit 100" }'
Response
{
"valid": true,
"ast": {
"v": 1,
"kind": "query",
"source": { "ontologies": ["ontology_7d1f4a2b9c3e5081"], "depths": ["B"] },
"filter": { "cond": { "prop": { "name": "Capacity" }, "op": "gt", "value": 70 } },
"select": [{ "dim": "friendly_id" }, { "prop": { "name": "Capacity" } }],
"limit": 100
},
"kind": "query"
}
Parse error
{
"error": {
"type": "invalid_request_error",
"code": "parse_error",
"message": "Expected \"|\" or end of query.",
"param": "dsl_text",
"errors": [
{
"message": "Expected \"|\" or end of query.",
"offset": 42,
"length": 6,
"expected": ["|"]
}
],
"request_id": "req_0a9f3c2b1e4d7f0a"
}
}
Print an AST as PassQL
The inverse of parse: renders an ast back into canonical PassQL text. Useful for showing a Blocks-built query as source, or for round-tripping a machine-built AST into something readable. Requires lab:read.
A body that is not an AST object is 422 invalid_ast; an AST that cannot be represented in PassQL is 422 unprintable_ast. Neither is positional, so error.errors is omitted.
Required attributes
- Name
ast- Type
- object
- Description
The AST to render as PassQL.
Request
curl https://api.synexcloud.com/v1/lab/dsl/print \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ast": {
"v": 1,
"kind": "query",
"source": { "ontologies": ["ontology_7d1f4a2b9c3e5081"], "depths": ["B"] },
"filter": { "cond": { "prop": { "name": "Capacity" }, "op": "gt", "value": 70 } },
"select": [{ "dim": "friendly_id" }, { "prop": { "name": "Capacity" } }],
"limit": 100
}
}'
Response
{
"dsl_text": "from ontology:\"EV Battery Pack\" depth:B | where p\"Capacity\" > 70 | select friendly_id, p\"Capacity\" | limit 100"
}
Preview a transform
Dry-runs a transform — a query whose AST carries a set stage — and returns its projected impact (how many passports it would change, plus up to 20 sampled before/after pairs) together with a preview_id. Nothing is written. You must pass that preview_id back to execute within 15 minutes; after that it expires.
Requires lab:write, plus releases:write when the transform assigns publish_status, and your team must be on the deployment's transform allowlist — otherwise 403 transforms_not_enabled. See PassQL's | set stage for how to build a transform AST.
A body that is not an AST object is 422 invalid_ast; a PassQL failure inside a well-formed AST is a compile error with the offending stages in error.errors.
Transforms and releases
set publish_status = "released" (or "draft", "archived", or null to clear the explicit status) is applied per passport under the same release rules as PATCH /v1/passports/{passport}. A transform releases exactly the passports it selected and never cascades. A passport whose ancestor is not released, a value write on a released passport, and a move from released to archived are refused for that passport alone: it is left untouched, recorded as a conflict on the run's changes, and the run carries on.
Required attributes
- Name
ast- Type
- object
- Description
A transform AST — a
source/filterselection plus asetstage. Build it in the Blocks builder or parse PassQL that contains aset.
The preview
- Name
preview_id- Type
- string
- Description
Token required to execute this exact transform.
- Name
affected_count- Type
- integer
- Description
How many passports the transform would change.
- Name
sample- Type
- array
- Description
Up to 20 sampled passports, each with its
snowflakeandfriendly_idand eitherchanges— per-propertyproperty_id,name,oldandnew, whereproperty_idis the literalpublish_statusfor a status assignment — or anerrorsaying why it could not be previewed. Never both.
- Name
expires_at- Type
- string
- Description
When the
preview_idstops being accepted, 15 minutes out.
Request
curl https://api.synexcloud.com/v1/lab/transforms/preview \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ast": {
"v": 1,
"kind": "transform",
"source": { "ontologies": ["ontology_7d1f4a2b9c3e5081"], "depths": ["B"] },
"filter": { "cond": { "prop": { "name": "Capacity" }, "op": "exists" } },
"set": [
{
"prop": { "name": "Capacity" },
"expr": {
"kind": "bin",
"op": "*",
"left": { "kind": "prop", "ref": { "name": "Capacity" } },
"right": { "kind": "lit", "value": 1.05 }
}
}
]
}
}'
Response
{
"preview_id": "4c1f9b60a7e25d3814f0c6b98a2e7d5310946fbc27e0a8d5619c3f47b0e2d8a1",
"affected_count": 1240,
"sample": [
{
"snowflake": "pass_3e9a1c5b7d2f4083",
"friendly_id": "PACK-0001",
"changes": [
{ "property_id": "property_capacity", "name": "Capacity", "old": 78.4, "new": 82.32 }
]
},
{
"snowflake": "pass_9c4e7a1d5b3f2064",
"friendly_id": "CELL-0001",
"error": "Cast failed for property \"Capacity\"."
}
],
"expires_at": "2026-07-22T14:25:43Z"
}
Execute a transform
Executes a transform you have just previewed. Pass the same ast along with the preview_id you were handed; if the preview has expired or no longer matches the AST, the call fails with 409 preview_mismatch — so you always confirm a fresh impact before anything is written. Values are recomputed from live passport data at apply time, and the run proceeds asynchronously: it returns 201 with a run_id you poll via retrieve a run.
Requires lab:write, plus releases:write when the transform assigns publish_status — the same gating as preview. A missing or malformed ast or preview_id is 422 parameter_invalid, with error.param naming whichever failed. Accepts an optional Idempotency-Key header.
Required attributes
- Name
ast- Type
- object
- Description
The transform AST — must match the one you previewed.
- Name
preview_id- Type
- string
- Description
The token from the matching preview, still within its 15-minute window.
Optional attributes
- Name
dsl_text- Type
- string
- Description
The PassQL source, stored on the run for display.
Request
curl https://api.synexcloud.com/v1/lab/transforms \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: capacity-uplift-2026-07-22" \
-d '{
"ast": {
"v": 1,
"kind": "transform",
"source": { "ontologies": ["ontology_7d1f4a2b9c3e5081"], "depths": ["B"] },
"filter": { "cond": { "prop": { "name": "Capacity" }, "op": "exists" } },
"set": [
{
"prop": { "name": "Capacity" },
"expr": {
"kind": "bin",
"op": "*",
"left": { "kind": "prop", "ref": { "name": "Capacity" } },
"right": { "kind": "lit", "value": 1.05 }
}
}
]
},
"preview_id": "4c1f9b60a7e25d3814f0c6b98a2e7d5310946fbc27e0a8d5619c3f47b0e2d8a1",
"dsl_text": "from ontology:\"EV Battery Pack\" depth:B | where p\"Capacity\" exists | set p\"Capacity\" = p\"Capacity\" * 1.05"
}'
Response
{
"object": "lab.run",
"run_id": 4822,
"status": "pending"
}
List runs
Lists your team's Lab runs — queries, transforms, rollbacks, backfills and exports — newest first. Returns a paginated list of run objects; drive it with has_more and next_cursor rather than assuming one request returns everything. Requires lab:read.
Optional attributes
- Name
limit- Type
- integer
- Description
Page size, 1–100 (default 10).
- Name
cursor- Type
- string
- Description
A pagination cursor from a previous response's
next_cursor.
Request
curl https://api.synexcloud.com/v1/lab/runs \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "list",
"data": [
{
"object": "lab.run",
"id": 4822,
"type": "transform",
"status": "complete",
"total_count": 1240,
"processed_count": 1240,
"failed_count": 0,
"conflict_count": 0,
"result_count": null,
"duration_ms": 18422,
"dsl_text": "from ontology:\"EV Battery Pack\" depth:B | where p\"Capacity\" exists | set p\"Capacity\" = p\"Capacity\" * 1.05",
"rolls_back_run_id": null,
"rolled_back_by_run_id": null,
"rollback_eligible": true,
"cache_hit": false,
"created_at": "2026-07-22T14:12:02Z",
"updated_at": "2026-07-22T14:12:20Z",
"finished_at": "2026-07-22T14:12:20Z"
},
{
"object": "lab.run",
"id": 4821,
"type": "query",
"status": "complete",
"total_count": null,
"processed_count": null,
"failed_count": null,
"conflict_count": null,
"result_count": 100,
"duration_ms": 47,
"dsl_text": "from ontology:\"EV Battery Pack\" depth:B | where p\"Capacity\" > 70 | select friendly_id, p\"Capacity\" | limit 100",
"rolls_back_run_id": null,
"rolled_back_by_run_id": null,
"rollback_eligible": false,
"cache_hit": false,
"created_at": "2026-07-22T14:10:43Z",
"updated_at": "2026-07-22T14:10:43Z",
"finished_at": "2026-07-22T14:10:43Z"
}
],
"has_more": true,
"next_cursor": "WyJpbXBvcnRfOGMyZDVlMWY5YTRiNzAxNiJd",
"url": "/v1/lab/runs"
}
Retrieve a run
Retrieves one run by its integer run id. Poll this after executing a transform, rollback or export until status reaches a terminal value — complete, partial, or failed (the counts break down what applied, failed, or conflicted). Requires lab:read.
Request
curl https://api.synexcloud.com/v1/lab/runs/4822 \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.run",
"id": 4822,
"type": "transform",
"status": "complete",
"total_count": 1240,
"processed_count": 1240,
"failed_count": 0,
"conflict_count": 0,
"result_count": null,
"duration_ms": 18422,
"dsl_text": "from ontology:\"EV Battery Pack\" depth:B | where p\"Capacity\" exists | set p\"Capacity\" = p\"Capacity\" * 1.05",
"rolls_back_run_id": null,
"rolled_back_by_run_id": null,
"rollback_eligible": true,
"cache_hit": false,
"created_at": "2026-07-22T14:12:02Z",
"updated_at": "2026-07-22T14:12:20Z",
"finished_at": "2026-07-22T14:12:20Z"
}
List a run's changes
Lists the individual property changes a transform or rollback recorded — one record per property per passport, each with the passport_snowflake, the property_id, the old_value/new_value, its status, and an error when the row failed or conflicted. Records are raw rows rather than a wrapped resource: no object and no id. Requires lab:read.
A transform over a large ontology produces far more of these than fit in one page, so this is a paginated list ordered by passport, then by property — walk has_more, or you will believe you have seen every change. status narrows the result set and therefore joins the cursor's scope: a cursor from the unfiltered walk replayed with a filter is 400 invalid_cursor.
Two property_id values are reserved and are not property ids: __error (the run could not evaluate the set-expression for that passport at all — one row per passport, status failed) and publish_status (the run assigned the passport's explicit publish status). Treat any property_id beginning with __ as reserved.
Optional attributes
- Name
status- Type
- string
- Description
Filter to one change status —
pending,applied,failed, orconflict.
- Name
limit- Type
- integer
- Description
Page size, 1–100 (default 10).
- Name
cursor- Type
- string
- Description
A pagination cursor from a previous response's
next_cursor.
Request
curl -G https://api.synexcloud.com/v1/lab/runs/4822/changes \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-d limit=100
Response
{
"object": "list",
"data": [
{
"passport_snowflake": "pass_3e9a1c5b7d2f4083",
"property_id": "property_capacity",
"old_value": 78.4,
"new_value": 82.32,
"status": "applied",
"error": null
},
{
"passport_snowflake": "pass_9c4e7a1d5b3f2064",
"property_id": "property_capacity",
"old_value": 0.68,
"new_value": 0.714,
"status": "conflict",
"error": "The value changed in DynamoDB after the run was planned."
}
],
"has_more": true,
"next_cursor": "WyJpbXBvcnRfOGMyZDVlMWY5YTRiNzAxNiJd",
"url": "/v1/lab/runs/4822/changes"
}
Roll back a transform
Queues a rollback of a transform, restoring every applied change to its recorded old_value. Only the team's latest finished, non-rolled-back transform is eligible — status complete or partial — and anything else returns 409 not_rollback_eligible, so check rollback_eligible on the run first. A run can be rolled back at most once: concurrent requests are resolved atomically, so exactly one is queued and the rest get 409 not_rollback_eligible.
Like executing a transform, this runs asynchronously, returns 201 with the new rollback run's id, and requires lab:write and the transform allowlist — plus releases:write when the run being rolled back assigned publish_status, since reversing it is a release transition too. Poll the returned run to completion.
Request
curl -X POST https://api.synexcloud.com/v1/lab/runs/4822/rollback \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.run",
"run_id": 4823,
"status": "pending"
}
Retrieve Lab usage
Returns your team's Lab footprint over the last 30 days — how much read-model storage it occupies and an accounting of runs by type, including the passport-store capacity (rcu, wcu) transforms consumed. Queries report zero. Requires lab:read.
The storage snapshot is recomputed when the last one is over a day old, so captured_at may trail the request. cache_hit_ratio is null when no queries ran in the window.
Request
curl https://api.synexcloud.com/v1/lab/usage \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.usage",
"storage": {
"passport_rows": 168420,
"value_rows": 1094730,
"heap_bytes": 412876800,
"index_bytes": 138412032,
"total_bytes": 551288832,
"captured_at": "2026-07-22T03:14:07Z"
},
"period_days": 30,
"runs": {
"by_type": {
"query": { "runs": 812, "duration_ms": 41903, "rcu": 0, "wcu": 0 },
"transform": { "runs": 6, "duration_ms": 92118, "rcu": 1240, "wcu": 1240 }
},
"query_count": 812,
"query_duration_ms": 41903,
"cache_hits": 331,
"cache_hit_ratio": 0.408,
"rcu_total": 1240,
"wcu_total": 1240
}
}
Enable Lab
Turns Lab on for the team. Requires lab:write — the ability transforms and rollbacks already carry, so it introduces no new concept. A key whose owner's role does not grant it is refused with 403 insufficient_permissions; re-issuing the key with more abilities does not help, because a key is capped by its owner's current permissions. The transform allowlist does not apply here: enabling Lab is not a mutation of anyone's data.
It does mean a first-run automation can provision Lab and query it without a human ever opening the console, which is the point of having the endpoint at all.
Enabling queues a backfill that mirrors the team's existing passports into the read model. Until it finishes, queries still answer — just from a partially warm index, which is indistinguishable from a genuinely small result set unless you check. The watermark, reported both on /v1/lab/meta and on every query result, is how you tell how far it has got; the response's backfill is the run to poll while you wait.
Enabling is idempotent. Calling it on an already-enabled team reports the current state, queues nothing, and emits no second event — and it reports the most recent backfill, so a caller that lost track can poll the run already in flight rather than starting a duplicate. It answers 200 with a lab.status object: Lab is a state of the team, not an object this call brings into existence.
Emits lab.enabled on the change feed.
Request
curl -X POST https://api.synexcloud.com/v1/lab/enable \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.status",
"enabled": true,
"enabled_at": "2026-07-22T14:09:58Z",
"backfill": {
"object": "lab.run",
"id": 4820,
"type": "backfill",
"status": "pending",
"total_count": null,
"processed_count": null,
"failed_count": null,
"conflict_count": null,
"result_count": null,
"duration_ms": null,
"dsl_text": null,
"rolls_back_run_id": null,
"rolled_back_by_run_id": null,
"rollback_eligible": false,
"cache_hit": false,
"created_at": "2026-07-22T14:09:58Z",
"updated_at": "2026-07-22T14:09:58Z",
"finished_at": null
}
}
Disable Lab
Opts back out and purges the team's rows from the read model. Requires lab:write, exactly as enabling does.
That is destructive to the Lab index only: the passports it mirrors are untouched, and the run ledger is kept as history — GET /v1/lab/runs still answers after a disable. Re-enabling therefore rebuilds from source rather than restoring, which is why backfill is always null on a disable response — the backfill that warmed the index is about to be purged along with it.
Disabling is idempotent in the same way enabling is: on a team that is already disabled it reports the current state, purges nothing, and emits no second event. Emits lab.disabled on the change feed.
Request
curl -X POST https://api.synexcloud.com/v1/lab/disable \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.status",
"enabled": false,
"enabled_at": null,
"backfill": null
}
Retrieve the Lab catalog
The ontology → depth → property catalog a client needs to author PassQL against this team, alongside the read model's watermark. Requires lab:read.
Without it a client has only two options, both bad: hard-code the vocabulary and break when a schema moves, or discover it by trial and error against query/validate.
Each ontology carries its id as ontology (not ontology_id) and the name a PassQL from clause matches on. Each depth carries its level key as depth, a name, and the properties you can query on it — id, name, type and unit, where unit is null for a property that has none. A versioned ontology reports the depths of its published graph.
Category properties are omitted. They exist to group other properties in the UI and are not queryable columns, so a catalog that listed them would be offering you names that cannot appear in a select or a where.
The catalog is never truncated. It pages the whole team internally rather than returning a first page, because a partial catalog is worse than a slow one: a missing ontology or depth would silently make a perfectly valid query look unwritable. The consequence is the honest one — on a large tenant this is a slow call. Fetch it once and cache it; it changes only when a schema does.
Request
curl https://api.synexcloud.com/v1/lab/meta \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.meta",
"ontologies": [
{
"ontology": "ontology_7d1f4a2b9c3e5081",
"name": "EV Battery Pack",
"depths": [
{
"depth": "B",
"name": "Pack",
"properties": [
{ "id": "property_capacity", "name": "Capacity", "type": "number", "unit": "kWh" },
{ "id": "property_chemistry", "name": "Chemistry", "type": "text", "unit": null }
]
},
{
"depth": "C",
"name": "Cell",
"properties": [
{ "id": "property_capacity", "name": "Capacity", "type": "number", "unit": "kWh" }
]
}
]
}
],
"watermark": "2026-07-22T14:10:41Z"
}
Export a run
Queues a new run, of type export, that re-executes the source run's query and materializes the result as a file — csv by default, or xlsx if you ask for it. Because the AST is re-executed rather than replayed, the export reflects the data as of the export, not as of the original query.
The 201 carries the export run, not the source run you named in the path. Poll that new id like any other run, then fetch the file.
It is lab:read, not lab:write — which reads like a mistake until you see why. An export materializes exactly what POST /v1/lab/query already returns to the same key. It creates no data and changes none; requiring a write ability would gate a read behind a write for no gain.
Only query runs can be exported. Point it at a transform, rollback, backfill or export run and you get 409 not_exportable rather than a 404 — the distinction is deliberate: the run exists and you are allowed to read it, it just is not the kind of thing that can be exported. A run belonging to another team, or no run at all, is 404 resource_missing.
An export is a read, and reads are not logged — it emits no event, matching what the application does. If you need a record that data left the platform in bulk, the run ledger is where it lives — GET /v1/lab/runs lists every export run, who queued it and when.
Optional attributes
- Name
format- Type
- string
- Description
csv(default) orxlsx. Anything else is400 parameter_invalid.
Request
curl https://api.synexcloud.com/v1/lab/runs/4821/export \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"format": "xlsx"}'
Response
{
"object": "lab.run",
"id": 4824,
"type": "export",
"status": "pending",
"total_count": null,
"processed_count": null,
"failed_count": null,
"conflict_count": null,
"result_count": null,
"duration_ms": null,
"dsl_text": "from ontology:\"EV Battery Pack\" depth:B | where p\"Capacity\" > 70 | select friendly_id, p\"Capacity\" | limit 100",
"rolls_back_run_id": null,
"rolled_back_by_run_id": null,
"rollback_eligible": false,
"cache_hit": false,
"created_at": "2026-07-22T14:14:09Z",
"updated_at": "2026-07-22T14:14:09Z",
"finished_at": null
}
Download an export
Returns a short-lived presigned URL, not the bytes, on exactly the same terms as GET /v1/files/{file}/download. Requires lab:read.
No Authorization header is needed to fetch it, so treat the URL as a bearer credential for that one object until expires_at — 15 minutes by default. expires_at is absolute, so you can hold the URL for its lifetime rather than re-requesting it per download.
An export still being written is 409 export_not_ready, not a 404 — the run is real, the file simply is not there yet, so poll the run until it completes rather than retrying the download blindly. A run that is not an export at all reads as 404 resource_missing.
Request
curl https://api.synexcloud.com/v1/lab/runs/4824/export/download \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.export_download",
"run": 4824,
"url": "https://synex-uploads.s3.eu-central-1.amazonaws.com/42/lab_export_4824.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=2c7f0a94…",
"expires_at": "2026-07-22T14:29:11Z"
}