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

A successful query returns the GraphQL data object itself as the response body — keyed by your root fields, with no data wrapper. Connections are Relay-compliant:

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

Errors use the standard Helm error envelope, not a GraphQL errors array. A document that fails to parse or validate, or carries bad arguments (an invalid cursor, first with last), is a 400; a valid document the engine refuses is a 422. error.details.errors[] lists each message with its code (GRAPHQL_VALIDATION_FAILED or BAD_USER_INPUT):

{
  "error": {
    "status": 400,
    "code": "BAD_REQUEST",
    "message": "GraphQL validation: Value \"asc\" does not exist in \"SortDirection\" enum. Did you mean the enum value \"ASC\" or \"DESC\"?",
    "details": {
      "errors": [
        {
          "message": "Value \"asc\" does not exist in \"SortDirection\" enum. Did you mean the enum value \"ASC\" or \"DESC\"?",
          "code": "GRAPHQL_VALIDATION_FAILED"
        }
      ]
    }
  }
}

#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_from_cardinality = 1 single ${Type} object
from side any other max_from_cardinality ${Type}Connection
to side max_to_cardinality = 1 single ${Type} object
to side any other max_to_cardinality ${Type}Connection
self-referential (always emits both directions) ${Type}Connection

max_from_cardinality caps the links one from entity has, so 1 means each parent on the from side has at most one related row (see Add a relationship rule).

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 literal — ASC / DESC (also ASC_NULLS_FIRST, DESC_NULLS_LAST, etc.), never the quoted string "ASC". Lowercase asc/desc is rejected with a 400 (GRAPHQL_VALIDATION_FAILED — see Response envelope).

#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, _fuzzy, _isNull
NumericComparison numeric (int / float) _eq, _neq, _in, _nin, _gt, _gte, _lt, _lte, _isNull
TimestampComparison timestamps as ISO strings _eq, _neq, _gt, _gte, _lt, _lte, _isNull
UUIDComparison uuid (id columns) _eq, _neq, _in, _nin, _isNull
BooleanComparison bool _eq, _neq, _isNull
ArrayComparison text_array attributes _contains (all of), _overlaps (any of), _eq, _isNull

_like is case-sensitive and accent-insensitive; _ilike is case-insensitive and accent-insensitive. _isNull: true matches rows where the attribute is absent or null, false where it is present — it is on every comparison input. _fuzzy is the typo-tolerant match and takes an object, { value: "...", threshold: 0.6 } (threshold optional); like its REST counterpart it needs searchable: true on the attribute to score real word similarity. 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

Filtering is open, sorting is gated. Every attribute declared in the type's JSON schema gets a field on ${TypeName}AttributesBoolExp, indexed or not — a non-indexed attribute is matched against the stored value directly, correct but slower than an indexed one. ${TypeName}AttributesOrderBy, by contrast, only exposes attributes configured sortable: true (see Entity type config & query capabilities) — referencing anything else in orderBy is a schema-validation error at parse time (there's no field for it), not a runtime lookup failure. Only top-level attributes (a single-segment path) can be sortable this way; a nested attribute path is never exposed in OrderBy even if its indexed-attribute config says sortable: true. This applies at every nesting level (a related type's own where/orderBy follows the same rule for that type). The REST POST /entity/query is looser — it sorts on any attribute, indexed or not (see Sorting).

#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 a 400 (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's rows reach yours through a manifest you accepted that has an entity member for the included type.
  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 Share with Another Tenant for the manifest and Shares & Grants for 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 with details.reason: "empty_query_schema" ("No queryable entity types are available: either this tenant has no entity types defined yet, or your entity_type:read grants don't include any.").

#Playground

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. To run the same read in a workflow, write it as a queryEntity step: the filter, sort and include primitives are the same ones the GraphQL arguments map to.

POST /entity/graphql answers standard __schema / __type introspection selections too, against the same tenant-scoped schema.