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.run this query was recorded as (e.g. 4821). Read it back with retrieve a run.

  • Name
    mode
    Type
    string
    Description

    rows for a row-per-passport result, groups for an aggregated one. Decided by the AST.

  • Name
    columns
    Type
    array
    Description

    Column descriptors in display order, each a {key, label, kind} object. key is what the row objects are keyed by; label is for headings and is not stable enough to match on.

  • Name
    rows
    Type
    array
    Description

    The result rows, each an object keyed by the key of the corresponding column.

  • Name
    next_cursor
    Type
    string
    Description

    Keyset cursor for the next page of a rows-mode result; null in groups mode and on the last page. Send it back as ast.cursor — see running a query. Nullable.

  • Name
    duration_ms
    Type
    integer
    Description

    How long the query took to execute, in milliseconds. Near zero when cached is 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 a 404, not a 400.

  • Name
    type
    Type
    string
    Description

    query, transform, rollback, backfill, or export.

  • Name
    status
    Type
    string
    Description

    pending until a worker picks the run up, running while workers fan out, then complete, partial (finished with failures or conflicts) or failed.

  • 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 status is running. 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=conflict on the run's changes. Nullable.

  • Name
    result_count
    Type
    integer
    Description

    Rows a query returned. null on 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. null on 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. null while it is pending or running.


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 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

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": {
      "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"
}

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 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

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": {
      "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"
  }
}

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 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

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:\"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"
  }
}

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.

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

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": {
      "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"
}

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

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 snowflake and friendly_id and either changes — per-property property_id, name, old and new, where property_id is the literal publish_status for a status assignment — or an error saying why it could not be previewed. Never both.

  • Name
    expires_at
    Type
    string
    Description

    When the preview_id stops being accepted, 15 minutes out.

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": {
      "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"
}

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 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

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: 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"
}

GET/v1/lab/runs

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

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": 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"
}

GET/v1/lab/runs/:run

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

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

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

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

  • 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

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

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

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

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

GET/v1/lab/usage

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

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

POST/v1/lab/enable

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

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

POST/v1/lab/disable

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

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

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 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.

Request

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

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. Because the AST is re-executed rather than replayed, the export reflects the data as of the export, not as of the original query.

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) or xlsx. Anything else is 400 parameter_invalid.

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": "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
}

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://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"
}

Was this page helpful?