Lab
Lab is the analytical engine over your passport data. It runs PassQL — read queries and bulk transforms — against a Postgres read model, so nothing here touches the passport store directly and every call is metered Postgres-only · 0 RCU. Run and validate queries, translate PassQL to and from an AST, preview and execute transforms, then track every change and roll the latest one back.
Lab must be enabled for your team in the app; until it is, every Lab endpoint returns 409 lab_not_enabled. Read operations require the lab:read ability. Previewing or executing a transform, and rolling one back, additionally require lab:write and a key owned by a team admin or owner; transforms also need your team to be 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 bundle types ast as a free-form object; the shapes shown below are illustrative of what parse emits.
The query result model
The result of running a query. Row queries return a columns/rows grid; grouped queries return one row per group. 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
Integer id of the run this query was recorded as (e.g.
4207). Queries are runs too.
- Name
mode- Type
- string
- Description
The result shape —
rowsfor a projection,groupsfor agroup by/aggresult.
- Name
columns- Type
- array
- Description
Ordered column descriptors for the result set (dimensions and property references).
- Name
rows- Type
- array
- Description
The result rows. Values come back in their raw PassQL shape — numbers as numbers, tags as lists.
- Name
next_cursor- Type
- string
- Description
Keyset cursor for the next page of rows, or
nullon the last page. See Pagination. Nullable.
- Name
duration_ms- Type
- integer
- Description
Server-side execution time in milliseconds.
- Name
cached- Type
- boolean
- Description
Whether the result was served from the auto-invalidating query cache.
- Name
watermark- Type
- integer or string
- Description
"Data as of" marker for read-model freshness. Nullable.
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 and you poll retrieve a run until status is terminal (complete, failed, or partial).
- Name
object- Type
- string
- Description
Always
lab.run.
- Name
id- Type
- integer
- Description
The run's id — a plain integer (e.g.
4210), not a prefixed string.
- Name
type- Type
- string
- Description
query,transform,rollback,backfill, orexport.
- Name
status- Type
- string
- Description
pending,running,complete,failed, orpartial. Apartialrun applied some passports and skipped or conflicted on others.
- Name
total_count- Type
- integer
- Description
Passports the run targets. Nullable.
- Name
processed_count- Type
- integer
- Description
Passports processed so far. Nullable.
- Name
failed_count- Type
- integer
- Description
Passports whose assignment failed and was skipped. Nullable.
- Name
conflict_count- Type
- integer
- Description
Passports flagged as conflicts (changed out from under the run). Nullable.
- Name
result_count- Type
- integer
- Description
Rows returned, for query runs. Nullable.
- Name
duration_ms- Type
- integer
- Description
Execution time in milliseconds. Nullable.
- Name
dsl_text- Type
- string
- Description
The PassQL source stored with the run, when supplied. 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. Nullable.
- Name
rollback_eligible- Type
- boolean
- Description
Whether this run is the team's latest completed, non-rolled-back transform and can currently be rolled back.
- 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. Nullable.
- Name
updated_at- Type
- string
- Description
ISO 8601 UTC timestamp of the last update. Nullable.
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 to compile returns 422 with the error envelope whose error.errors lists the offending stages.
Required attributes
- Name
ast- Type
- object
- Description
The compiled PassQL query AST — a
fromclause plus stages. Obtain one fromPOST /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.
Request
curl https://api.synexcloud.com/v1/lab/query \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ast": {
"from": { "ontologies": ["ontology_00a1b2c3d4e5f607"], "depth": ["C"] },
"where": [{ "property": "Weight", "op": ">", "value": 10 }]
},
"dsl_text": "from ontology:\"Battery X\" depth:C | where p\"Weight\" > 10"
}'
Response
{
"object": "lab.query_result",
"run_id": 4207,
"mode": "rows",
"columns": ["friendly_id", "tppmp", "Weight"],
"rows": [
["EU-00417", "tppmp_9f2k7d", 12.5],
["EU-00419", "tppmp_3a8c1e", 41.0]
],
"next_cursor": "cursor_c2Vjb25kcGFnZQ",
"duration_ms": 84,
"cached": false,
"watermark": "2026-07-16T11:59:40Z"
}
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 returns the mode it would produce; an invalid one returns 422 with valid: false and the compile feedback.
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": {
"from": { "ontologies": ["ontology_00a1b2c3d4e5f607"], "depth": ["C"] },
"where": [{ "property": "Weight", "op": ">", "value": 10 }]
}
}'
Response
{
"valid": true,
"mode": "rows"
}
Failure
{
"error": {
"type": "invalid_request_error",
"code": "parse_error",
"message": "Expected a property at offset 42.",
"param": "dsl_text",
"request_id": "req_0a9f3c2b1e4d7f0a",
"errors": [
{
"message": "expected a property",
"offset": 42,
"length": 6,
"expected": ["p\"...\""]
}
]
}
}
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 PassQL returns 422 with positioned errors — each carries a message, a byte offset, a length, and what was expected.
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:\"Battery X\" depth:C | where p\"Weight\" > 10" }'
Response
{
"valid": true,
"kind": "query",
"ast": {
"from": { "ontologies": ["ontology_00a1b2c3d4e5f607"], "depth": ["C"] },
"where": [{ "property": "Weight", "op": ">", "value": 10 }]
}
}
Parse error
{
"error": {
"type": "invalid_request_error",
"code": "parse_error",
"message": "Expected a property at offset 42.",
"param": "dsl_text",
"request_id": "req_0a9f3c2b1e4d7f0a",
"errors": [
{
"message": "expected a property",
"offset": 42,
"length": 6,
"expected": ["p\"...\""]
}
]
}
}
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.
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": {
"from": { "ontologies": ["ontology_00a1b2c3d4e5f607"], "depth": ["C"] },
"where": [{ "property": "Weight", "op": ">", "value": 10 }]
}
}'
Response
{
"dsl_text": "from ontology:\"Products\" depth:C | where p\"Weight\" > 10"
}
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 touch, plus a before/after sample) 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 and a key owned by a team admin or owner, 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; setting publish_status pins it, and clearing it lets the passport inherit again.
Required attributes
- Name
ast- Type
- object
- Description
A transform AST — a
from/whereselection plus asetstage. Build it in the Blocks builder or parse PassQL that contains aset.
Request
curl https://api.synexcloud.com/v1/lab/transforms/preview \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ast": {
"from": { "ontologies": ["ontology_00a1b2c3d4e5f607"] },
"where": [{ "property": "Origin", "op": "is_empty" }],
"set": [{ "property": "Origin", "value": "EU" }]
}
}'
Response
{
"object": "lab.transform_preview",
"preview_id": "labprev_5f6071829abc",
"affected_count": 128,
"expires_at": "2026-07-16T12:15:00Z",
"sample": [
{
"passport": "pass_00a1b2c3d4e5f607",
"property_id": "property_7c3e1a",
"old_value": null,
"new_value": "EU"
}
]
}
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 a run_id you poll via retrieve a run.
Requires lab:write and a team admin or owner, the same gating as preview. 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: a1b2c3d4-transform-01" \
-d '{
"ast": {
"from": { "ontologies": ["ontology_00a1b2c3d4e5f607"] },
"where": [{ "property": "Origin", "op": "is_empty" }],
"set": [{ "property": "Origin", "value": "EU" }]
},
"preview_id": "labprev_5f6071829abc",
"dsl_text": "from ontology:\"Products\" | where p\"Origin\" is_empty | set p\"Origin\" = \"EU\""
}'
Response
{
"object": "lab.run",
"run_id": 4210,
"status": "pending"
}
List runs
Lists your team's 50 most recent Lab runs — queries, transforms, rollbacks, and exports — newest first. This is a fixed-size window rather than a paginated cursor list. Returns a list envelope of run objects. Requires lab:read.
Request
curl https://api.synexcloud.com/v1/lab/runs \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "list",
"data": [
{
"object": "lab.run",
"id": 4210,
"type": "transform",
"status": "complete",
"total_count": 128,
"processed_count": 128,
"failed_count": 0,
"conflict_count": 0,
"result_count": null,
"duration_ms": 5120,
"dsl_text": "from ontology:\"Products\" | where p\"Origin\" is_empty | set p\"Origin\" = \"EU\"",
"rolls_back_run_id": null,
"rolled_back_by_run_id": null,
"rollback_eligible": true,
"cache_hit": false,
"created_at": "2026-07-16T12:02:00Z",
"updated_at": "2026-07-16T12:02:05Z"
}
],
"has_more": false,
"next_cursor": null,
"url": "/v1/lab/runs"
}
Retrieve a run
Retrieves one run by its integer run id. Poll this after executing a transform or rollback until status reaches a terminal value — complete, failed, or partial (the counts break down what applied, failed, or conflicted). Requires lab:read.
Request
curl https://api.synexcloud.com/v1/lab/runs/4210 \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.run",
"id": 4210,
"type": "transform",
"status": "complete",
"total_count": 128,
"processed_count": 128,
"failed_count": 0,
"conflict_count": 0,
"result_count": null,
"duration_ms": 5120,
"dsl_text": "from ontology:\"Products\" | where p\"Origin\" is_empty | set p\"Origin\" = \"EU\"",
"rolls_back_run_id": null,
"rolled_back_by_run_id": null,
"rollback_eligible": true,
"cache_hit": false,
"created_at": "2026-07-16T12:02:00Z",
"updated_at": "2026-07-16T12:02:05Z"
}
List a run's changes
Lists the individual property changes a transform or rollback recorded — up to 200 records, each pinning the passport, the property_id, the old_value/new_value, its status, and an error when the assignment was skipped. Filter with the optional status query parameter to isolate, for instance, only the conflicts. Requires lab:read.
Optional attributes
- Name
status- Type
- string
- Description
Filter to one change status —
pending,applied,failed, orconflict.
Request
curl -G https://api.synexcloud.com/v1/lab/runs/4210/changes \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-d status=applied
Response
{
"object": "list",
"data": [
{
"passport": "pass_00a1b2c3d4e5f607",
"property_id": "property_7c3e1a",
"old_value": null,
"new_value": "EU",
"status": "applied",
"error": null
}
],
"has_more": false,
"next_cursor": null,
"url": "/v1/lab/runs/4210/changes?status=applied"
}
Roll back a transform
Queues a rollback of a completed transform, restoring every applied change to its recorded old_value. Only the team's latest completed, non-rolled-back transform is eligible — anything else returns 409 not_rollback_eligible, so check rollback_eligible on the run first. Like executing a transform, this runs asynchronously and requires lab:write plus a team admin or owner. Poll the returned rollback run to completion.
Request
curl -X POST https://api.synexcloud.com/v1/lab/runs/4210/rollback \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.run",
"run_id": 4213,
"type": "rollback",
"status": "pending",
"rolls_back_run_id": 4210
}
Retrieve Lab usage
Returns your team's Lab footprint — how much read-model storage it occupies and an accounting of runs by type. Requires lab:read.
Request
curl https://api.synexcloud.com/v1/lab/usage \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "lab.usage",
"storage_bytes": 48210944,
"passport_rows": 128044,
"runs": {
"queries": 512,
"transforms": 24,
"rollbacks": 3
},
"watermark": "2026-07-16T11:59:40Z"
}
Enable Lab
Turns Lab on for the team. Requires lab:write and a key whose owner is a team admin or owner — without the role it is 403 lab_role_required.
That is the same role gate transforms and rollbacks already carry, so it introduces no new concept — but 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. 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 field names 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.
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",
"enabled": true,
"enabled_at": "2026-07-28T10:02:14Z",
"backfill": 4818
}
Disable Lab
Opts back out and purges the team's rows from the read model. Requires lab:write and the same admin/owner role gate.
That is destructive to the Lab index only: the passports it mirrors are untouched, and the run ledger is kept as history. 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.
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",
"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 depth carries a level key, 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.
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.
Lab must be enabled; otherwise this is 409 lab_not_enabled.
Request
curl https://api.synexcloud.com/v1/lab/meta \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"ontologies": [
{
"id": "ontology_7d1f4a2b9c3e5081",
"name": "EV Battery Pack",
"depths": [
{
"level": "B",
"name": "Pack",
"properties": [
{
"id": "property_capacity",
"name": "Capacity",
"type": "number",
"unit": "kWh"
},
{
"id": "property_chemistry",
"name": "Chemistry",
"type": "text",
"unit": null
}
]
}
]
}
],
"watermark": "2026-07-27T09:14:02Z"
}
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.
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.
An export writes no audit-log entry, 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.
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": "queued",
"source_run": 4821,
"format": "xlsx",
"created_at": "2026-07-28T11:05:33Z"
}
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://s3.eu-west-1.amazonaws.com/…?X-Amz-Signature=…",
"expires_at": "2026-07-28T11:22:47Z"
}