Authorize

#GraphQL: the entity graph API

POST /entity/graphql serves nested entity reads as a Relay-style GraphQL surface. Every read is authorized exactly like the REST query API (POST /entity/query): same cross-tenant shares, same attribute filters, same policy mode. If your principal can read a row through REST, it can read it through GraphQL — and not otherwise.

Most integrations don't need GraphQL for this. For a nested, graph-shaped read, POST /entity/query-graph (REST — see Entities & Data Model) is the more commonly used surface: pass a nested include tree and get back one deduplicated graph (roots / nodes / edges), no query language required. Reach for GraphQL below when you specifically want field-level selection, Relay Connections, or codegen.

Use GraphQL when you want:

  • One round-trip for nested relationships. The include tree is derived from your selection set — no include_specs array to assemble by hand.
  • Selective fields. Only the fields you select cross the wire.
  • A familiar shape. Relay Connections (edges / nodes / pageInfo / totalCount) plus a single-row ${TypeName}ById root field, both codegen-friendly.

REST POST /entity/query is still the right call for one-off lookups, bulk admin reads, and CSV exports; POST /entity/query-graph for nested reads without GraphQL.

#Calling the endpoint

Send a standard GraphQL request body — query plus optional variables — to POST /entity/graphql. Authenticate with HTTP Basic using your PAT.

POST/entity/graphqlRun a GraphQL queryAPI docs ↗Try it

#Schema generation rules

For each entity type the schema generator emits:

  • Two root fields (root fields are PascalCase, named after the type):

    • ${TypeName}(where, orderBy, first, after, last, before, countStrategy): ${TypeName}Connection — a Relay Connection over rows.
    • ${TypeName}ById(id: ID!): ${TypeName} — single-row fetch; returns the entity or null when the row doesn't exist or the caller can't see it.
  • One object type ${TypeName} with:

    • System fields id: ID!, name, identifierKey, createdAt, updatedAt — all camelCase.
    • attributes: ${TypeName}Attributes — a nested object holding every user-defined attribute (see below).
    • One field per relationship rule that touches this type, named after the related entity type (PascalCase, not the rule name). Singular when the parent-side cardinality is 1, otherwise a Connection.
  • Filter/sort input types: ${TypeName}BoolExp, ${TypeName}AttributesBoolExp, ${TypeName}OrderBy, ${TypeName}AttributesOrderBy.

#Attributes are nested

Every user-defined attribute lives under a top-level attributes: field — not as a sibling of id / name:

query OneCert {
  VesselCertificateById(id: "…") {
    id
    name
    attributes { type expiry_date issued_date issuer }
  }
}

Namespacing attributes this way lets a catalogue name an attribute name, id, or created_at without shadowing the system fields. Types with zero mappable attributes have no attributes: field at all — query only their system and relationship fields.

#Response envelope

The response is Relay-compliant and stable:

{
  "data": {
    "<TypeName>": {
      "edges": [
        {
          "node": {
            "id": "...",
            "name": "...",
            "identifierKey": "...",
            "createdAt": "...",
            "updatedAt": "...",
            "attributes": {
              "expiry_date": "2027-01-15T00:00:00Z",
              "type": "ISSC"
            },
            // single-cardinality relationship → object
            "Vessel": {
              "id": "...", "name": "MV …",
              "attributes": { "imo_number": "9123456" }
            },
            // list relationship → Connection
            "BridgeDocument": {
              "nodes": [ { "id": "...", "attributes": { "document_name": "…" } } ],
              "edges": [ { "node": {...}, "cursor": "…" } ],
              "pageInfo": { "hasNextPage": false, "hasPreviousPage": false },
              "totalCount": 1
            }
          },
          "cursor": "eyJpZCI6…"
        }
      ],
      "nodes": [ /* same node objects as edges[i].node, flat */ ],
      "pageInfo": {
        "startCursor": "eyJpZCI6…",
        "endCursor":   "eyJpZCI6…",
        "hasNextPage": true,
        "hasPreviousPage": false
      },
      "totalCount": 42                // only present when selected
    }
  }
}

nodes is a convenience flat list — the same objects as edges[*].node. Use nodes when you don't need cursors; use edges when you do.

#Cardinality cheat sheet

Whether a relationship surfaces as a Connection or a single object depends on the rule's cardinality from the parent's perspective:

Parent side Rule shape GraphQL field type
from side max_to_cardinality = 1 single ${Type} object
from side max_to_cardinality = null ${Type}Connection
to side max_from_cardinality = 1 single ${Type} object
to side max_from_cardinality = null ${Type}Connection
self-referential (always emits both directions) ${Type}Connection

Selecting { nodes { id } } on a single-object field — or { id } on a Connection — is rejected at validation. Match the shape.

#Filters, sort, pagination

Filter through where (a typed BoolExp); sort through orderBy:

query CertsExpiringSoon($cursor: String) {
  VesselCertificate(
    where: {
      _and: [
        { attributes: { type:        { _eq: "SMC" } } },
        { attributes: { expiry_date: { _lt: "2026-12-31T00:00:00Z" } } }
      ]
    }
    orderBy: [{ attributes: { expiry_date: ASC } }]
    first: 50
    after: $cursor
  ) {
    nodes { id name attributes { type expiry_date } }
    pageInfo { hasNextPage endCursor }
    totalCount
  }
}

SortDirection is an uppercase enum literalASC / DESC (also ASC_NULLS_FIRST, DESC_NULLS_LAST, etc.), never the quoted string "ASC". Lowercase asc/desc is rejected with 400 GRAPHQL_VALIDATION_FAILED.

#where operators (BoolExp)

Operators are underscore-prefixed. Each attribute or system-field input is typed by its value:

Comparison input Applies to Operators
StringComparison strings, including timestamps as ISO _eq, _neq, _in, _nin, _gt, _gte, _lt, _lte, _like, _ilike
NumericComparison numeric (int / float) _eq, _neq, _in, _nin, _gt, _gte, _lt, _lte
TimestampComparison timestamps as ISO strings _eq, _neq, _gt, _gte, _lt, _lte
UUIDComparison uuid (id columns) _eq, _neq, _in, _nin
BooleanComparison bool _eq
ArrayComparison text_array attributes element-set ops

_like is case-sensitive and accent-insensitive; _ilike is case-insensitive and accent-insensitive. Compose with _and, _or, _not:

where: {
  _or: [
    { name: { _ilike: "atlantic%" } },
    { attributes: { flag: { _in: ["Liberia", "Panama"] } } }
  ]
}

#Relationship existence in where

The BoolExp for a type carries a field named after every related type. A non-null BoolExp on that field means "at least one related row matches":

query VesselsWithExpiringISSC {
  Vessel(
    where: {
      VesselCertificate: {
        _and: [
          { attributes: { type:        { _eq: "ISSC" } } },
          { attributes: { expiry_date: { _lt: "2026-12-31T00:00:00Z" } } }
        ]
      }
    }
  ) {
    nodes { id name }
  }
}

#Filterable / sortable attributes

Filter and sort paths must reference an indexed attribute on the type. A path the engine can't resolve returns 400 INVALID_FILTER, not a silent no-op — the same rule as REST, and it applies at every nesting level.

#Cursor pagination

Forward with first / after; backward with last / before:

query Page1 {
  VesselCertificate(first: 50) {
    nodes { id name }
    pageInfo { endCursor hasNextPage }
  }
}

query PageN($cursor: String!) {
  VesselCertificate(first: 50, after: $cursor) {
    nodes { id name }
    pageInfo { endCursor hasNextPage }
  }
}

Rules:

  • after is mutually exclusive with before; first with last.
  • The cursor is opaque — built from the row's orderBy keys. Feed it back unmodified.
  • pageInfo.startCursor / endCursor are computed only when one of them (or edges.cursor) is selected — request them explicitly when paginating.

#Count strategy

totalCount is capped at 10001 by default — page renders shouldn't block on a full count. Override with countStrategy (an enum literal, not a string):

query {
  VesselCertificate(countStrategy: ESTIMATE) {
    nodes { id }
    totalCount
  }
}

countStrategy is an uppercase enum literal, like SortDirection — never a quoted string. Options: CAPPED (default, fast), ESTIMATE (sub-ms, can be stale), EXACT (linear scan — only for small result sets).

#Nested filter / sort / limit (depth-1)

Connection relationship fields accept their own where, orderBy, and first, applied to the related entity at that level:

query {
  Vessel(first: 20) {
    nodes {
      id name
      VesselCertificate(
        where:   { attributes: { type: { _eq: "SMC" } } }
        orderBy: [{ attributes: { expiry_date: ASC } }]
        first:   5
      ) {
        nodes { name attributes { expiry_date } }
        totalCount
      }
    }
  }
}

Nested pagination cursors (after / before / last) are not plumbed through on relationship fields — only where / orderBy / first are honoured there. Top-level cursor pagination works as normal.

#Single-row fetch — ${TypeName}ById

For "load this one row," use the dedicated by-id field. It returns null when the row doesn't exist or isn't visible to the caller:

query OneVessel($id: ID!) {
  VesselById(id: $id) {
    id name
    attributes { imo_number flag }
    VesselCertificate {
      nodes { id name attributes { type expiry_date } }
    }
  }
}

Prefer this over Vessel(where: { id: { _eq: $id } }) { nodes }[0] — it's clearer at the call site and skips the Connection wrapper.

#Depth cap

POST /entity/graphql hydrates up to 6 levels of nested relationships. A relationship selection deeper than that is rejected at query-build time with BAD_USER_INPUT ("exceeds the maximum nested include depth") rather than resolving to empty data — shorten the path or split it into separate root queries. Scalar-only selections past the cap are fine.

#Authorization & access control

You don't have to reason about cross-tenant rows, shares, attribute filters, or policy mode — they're enforced uniformly with the REST query API. If your principal can't read a row through POST /entity/query, you can't read it through GraphQL. There is no second access path.

In practice:

  • Cross-tenant shares surface shared rows under every relationship that points at the shared type.
  • Attribute filters apply at every depth — a row hidden at the root is also hidden as a nested neighbour.
  • Policy mode reads behave identically to REST reads in policy mode.

#"The include is empty but I know rows exist"

When a relationship resolves to null (single) or { nodes: [] } (Connection) and you can confirm the rows exist in the owner tenant, the cause is almost always a missing access leg, not a bug. A cross-tenant include needs both:

  1. The share. The owner tenant has an active share on the included type to your tenant.
  2. The grant. Your policy carries a read grant on entities of the included type. Policy-mode tokens (the default for app-minted tokens) see only what their policy grants.

Missing either leg returns silently — the parent row is delivered and the included field is empty — because both legs go through the same engine path as REST POST /entity/query. See Apps (Bridge) for how to set up the share and the consumer-side grant.

#Schema-build grants

The per-tenant schema is built from the types your principal can read. A spec policy needs only entity_type:read + entity:read on each type the app queries — the shape that describes a readable type (its attribute schema and relationship rules) is implied by entity_type:read. Minimum surface for a Vessel grid:

{ "resource_type": "entity_type", "resource_id": "<Vessel-type-id>", "actions": ["read"] },
{ "resource_type": "entity",      "resource_id": "<Vessel-type-id>", "actions": ["read"] }

If a principal can read no types, the schema is empty and the endpoint returns 422 "No entity types are readable for this principal — check your entity_type:read grants".

#Playground & workflow converter

GET /entity/graphql/schema returns the tenant-scoped schema — standard introspection JSON by default, or SDL with ?sdl=1. GraphQL clients (the console playground, codegen, ad-hoc tooling) read it for docs and autocomplete, so you get a typed editor against your own catalogue. The playground also includes a converter that turns a GraphQL query into a workflow queryEntity step, so a read you prototype interactively drops straight into a workflow definition.

Introspection is served only by this schema endpoint; the POST /entity/graphql query endpoint rejects __schema / __type selections.