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 — rows for a projection, groups for a group by / agg result.

  • 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 null on 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, or export.

  • Name
    status
    Type
    string
    Description

    pending, running, complete, failed, or partial. A partial run 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.


POST/v1/lab/query

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 from clause plus stages. 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.

Request

POST
/v1/lab/query
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"
}

POST/v1/lab/query/validate

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

POST
/v1/lab/query/validate
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\"...\""]
      }
    ]
  }
}

POST/v1/lab/dsl/parse

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

POST
/v1/lab/dsl/parse
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\"...\""]
      }
    ]
  }
}

POST/v1/lab/dsl/print

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

POST
/v1/lab/dsl/print
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"
}

POST/v1/lab/transforms/preview

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/where selection plus a set stage. Build it in the Blocks builder or parse PassQL that contains a set.

Request

POST
/v1/lab/transforms/preview
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"
    }
  ]
}

POST/v1/lab/transforms

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

POST
/v1/lab/transforms
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"
}

GET/v1/lab/runs

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

GET
/v1/lab/runs
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"
}

GET/v1/lab/runs/:run

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

GET
/v1/lab/runs/4210
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"
}

GET/v1/lab/runs/:run/changes

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, or conflict.

Request

GET
/v1/lab/runs/4210/changes
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"
}

POST/v1/lab/runs/:run/rollback

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

POST
/v1/lab/runs/4210/rollback
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
}

GET/v1/lab/usage

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

GET
/v1/lab/usage
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"
}

POST/v1/lab/enable

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

POST
/v1/lab/enable
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
}

POST/v1/lab/disable

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

POST
/v1/lab/disable
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
}

GET/v1/lab/meta

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.

Lab must be enabled; otherwise this is 409 lab_not_enabled.

Request

GET
/v1/lab/meta
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"
}

POST/v1/lab/runs/:run/export

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.

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) or xlsx.

Request

POST
/v1/lab/runs/4821/export
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"
}

GET/v1/lab/runs/:run/export/download

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

GET
/v1/lab/runs/4824/export/download
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"
}

Was this page helpful?