Remote entities
A remote entity has no table. Every operation against it is turned into exactly one outbound HTTP
request, and the response body is mapped back into rows. The engine compiles no SQL for it: joins,
ORDER BY and LIMIT belong to the upstream service, and so do the row-level and field-level
rules for its data (see Security).
Declaring one
Section titled “Declaring one”Set materialization: remote on the entity and add a
remote: block.
entities: - name: shipments materialization: remote fields: - name: id type: string - name: reference type: string - name: status type: string access: actions: read: { language: expr, expression: "true" } create: { language: expr, expression: "true" } remote: name: carrier base_url: https://carrier.example.com timeout: 5s auth: type: bearer token: ${secret.CARRIER_TOKEN} getlist: pre: template: url: /v1/shipments method: GET headers: X-Account: "{{user.tenant}}" post: expression: .data.items create: pre: template: url: /v1/shipments method: POST body: | {"reference": "{{payload.data.reference}}"} post: expression: .data| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | — | Short identifier for the remote system. Logs and metrics only. |
protocol | rest | graphql | rest | graphql sends every operation as a POST unless its template names a method, and takes request values as variables. See GraphQL. |
base_url (alias baseurl) | string | — | Endpoint root. Relative template URLs are joined onto it. |
path | string | — | Fallback URL used when an operation’s template sets no url. |
timeout | duration string | 10s | Per-request wall clock. Unparseable or non-positive values fall back to the default. |
auth | map | — | Credential scheme. See Auth. |
create get getlist update delete | map | — | Per-operation request template and response expression. |
The block is decoded strictly: any other key, including a misspelling of one of these, fails the
load and names the line. materialization: remote and a remote: block go together, and the load
fails when either one is declared without the other.
Operation blocks
Section titled “Operation blocks”| Block | Dispatched for | Default method |
|---|---|---|
create | Create | POST |
get | Read pinned to a single primary-key value | GET |
getlist | Every other read | GET |
update | Update, and Restore | PUT |
delete | Delete | DELETE |
An operation block takes pre.template and post.expression. post.expression is compiled at
load, so an expression that is not valid jq fails the load. An operation block with neither pre
nor post is dropped at load. Attempting an operation whose block is missing fails at request
time; a remote entity never falls back to SQL.
Restore maps onto update; there is no separate restore block. The soft-delete rewrite the SQL path
performs does not happen here, so ?purge=true reaches nothing and the upstream service decides what
a delete means.
URL resolution
Section titled “URL resolution”- The operation template’s
url. A template written as an absolute URL (http://,https://) is used as written; any other template is relative. Whether a URL is absolute is read from the template, so a substituted value never makes it one. - An empty
urlfalls back toremote.path. - A relative result is appended to
base_urlwith its trailing slash trimmed.
auth.type | Header sent |
|---|---|
bearer | Authorization: Bearer <token> |
apikey | <auth.header>: <token>, header defaulting to X-API-Key |
basic | Authorization: Basic <base64(user:password)> |
| anything else, or absent | none |
Matching is case-insensitive. An empty credential sends no header at all rather than an empty one.
token, user and password expand ${secret.NAME} only; they are substituted with no bindings
attached, so a {{…}} reference inside a credential always renders empty. A secret resolves through
the vault in the scope of the requesting tenant, so the same ${secret.CARRIER_TOKEN} is each
tenant’s own token when each tenant has one. A secret that cannot be resolved (no vault configured,
a lookup error, an empty value) refuses the call rather than sending it without the credential.
Template substitution
Section titled “Template substitution”url, body and every header value are filled from the request before it is built. A request
carrying a non-empty body and no explicit Content-Type header is sent as application/json.
| Form | Resolves to |
|---|---|
{{payload.data.<field>}} | The mutation payload after the pre-execute hooks have shaped it. |
{{payload.id}} | The primary-key value the engine was able to recover. |
{{filter.<field>}} | A value from the request’s filter map. |
{{user.id}} | The calling user’s id. |
{{user.tenant}} | The calling tenant’s key. |
{{user.roles}} | The calling user’s roles, as a JSON array: ["clerk","manager"]. |
{{entity}} | The entity name. |
${secret.NAME} | The secret from the vault, for the requesting tenant. Bodies, headers and credentials only: a secret in url refuses the call, since URLs are logged. |
${env.NAME} | Refused. The process environment holds the service’s own secrets; the schema fails to load, naming where the reference sits. |
Encoding
Section titled “Encoding”The template’s own text fixes the shape of the request. A value fills a slot in it and is encoded for where that slot sits, so a value can never add a JSON member, a query parameter, a path segment or a header.
| Slot | Encoding |
|---|---|
URL path, before the template’s first ? | Escaped as one path segment. A value that is empty, . or .., or that contains a . or .. segment, refuses the call. |
URL query, after ? | Escaped as one query value. |
JSON body, inside a string literal: "{{payload.data.name}}" | Escaped as string content. |
JSON body, standing alone: {{payload.data.qty}} | One whole JSON value of the value’s type: a number, a boolean, a string, an array, an object, or null. |
Form body (Content-Type: application/x-www-form-urlencoded) | Escaped as one form value. |
| Header value | Inserted as text. A value with a line break refuses the call. |
A JSON body template must render to valid JSON; a template that does not refuses the call. A body of any other content type takes secrets only, and a request value in it refuses the call.
A slot whose path resolves to nothing renders as null when it stands alone in a JSON body, and as
an empty string in a JSON string, a query value or a header. Test each template with a real
payload, and declare a required check at the owning service for any value it depends on.
GraphQL
Section titled “GraphQL”With protocol: graphql, the query is part of the declaration and request values travel as
variables. A request value inside the top-level query member, or inside each query of a batch,
refuses the call.
remote: name: carrier protocol: graphql base_url: https://carrier.example.com get: pre: template: url: /graphql body: | { "query": "query($id: ID!) { shipment(id: $id) { id status } }", "variables": { "id": "{{payload.id}}" } } post: expression: .data.shipmentWhere a remote entity may call
Section titled “Where a remote entity may call”A remote entity calls out from inside the platform on a tenant’s behalf, so every call is held to three rules:
- The destination is the host the entity declares:
base_url, or an absolute per-operationurl. Request values fill in the path; they cannot change the host. A declaration whose host is itself templated is refused. A redirect may not leave the declared host. - Public addresses only, unless the operator names the host. The address a name resolves to is
checked when the connection is made. Loopback, private and carrier-grade NAT addresses are refused
unless the host is listed in
remote.internalhosts; link-local addresses, where cloud instance metadata lives, are refused whatever is listed. - Tenant overlays cannot declare remote entities. A remote entity in a tenant’s
tenant_extensionsrows is dropped with a diagnostic; remote entities are the product’s to declare.
# boot documentremote: internalhosts: [crm.internal, 10.0.4.12] # hosts that may resolve to internal addressesSee Configuration for remote.internalhosts and the
test-only remote.allowprivate.
Response extraction
Section titled “Response extraction”The body is read with a hard 32 MiB cap and parsed as JSON. The cap truncates rather than errors, so
a larger response is cut mid-document and then decodes to nothing. A body that is not JSON, an empty
DELETE response, for example, decodes to nothing rather than erroring too.
post.expression is a jq expression applied to the parsed body. An empty expression returns the
parsed body unchanged. The result is then normalised:
| Expression yields | Rows returned |
|---|---|
| An object | One row. |
| An array | One row per element; a non-object element becomes {"value": <element>}. |
| A scalar | One row, {"value": <scalar>}. |
Nothing, or null | No rows. |
| Several values | The values as a list, normalised as an array. |
Whatever the expression yields is what the caller receives, the entity’s declared fields: do not
project, coerce, default or mask the response.
The execution path
Section titled “The execution path”The engine dispatches on materialization before it does anything else, then runs a reduced version of the hook pipeline around the HTTP call.
| Phase | Table entity, read | Remote, read | Table entity, write | Remote, write |
|---|---|---|---|---|
BeforeValidate | runs | runs | runs | runs |
AfterValidate | runs | skipped | runs | runs |
| compile to SQL | runs | skipped | runs | skipped |
BeforeExec | runs | skipped | runs | skipped |
| execute | driver | HTTP request | driver | HTTP request |
AfterExec | — | — | runs | runs |
AfterScan | runs | runs | — | — |
In place of the compile step, a remote call is checked against the service allow-list and the operation’s action rule before the request is sent, the same checks the compiler makes for a table entity.
Consequences, in the order you meet them:
- Pointcuts registered at
BeforeExecrun on table entities only:BeforeCascadeandBeforeCacheRead. - On reads,
AfterValidateis skipped as well, soOnRLSFilterand any read-sideAfterValidatework run on table entities only. - Payload edits still stop mattering after
AfterValidate, exactly as on the SQL path: the payload is snapshotted into the outbound body at that point. - The relation eager-load step is part of the SQL read path only, so
?include=resolves nothing on a remote entity.
Declarative machinery that still applies to a remote entity: field defaults, field and entity
validations, audit-column stamping, stateflow guards,
before_* triggers, Before* pointcuts on create/update/delete, After* pointcuts at AfterExec,
the read-side BeforeQuery / BeforeAggregate and AfterQuery / AfterAggregate pointcuts, and
read-side computed fields at AfterScan.
Query capability
Section titled “Query capability”Reads reach the upstream service through the filter bindings and nothing else. Dispatch happens
before the engine builds a query, and an AST the caller already assembled is discarded unread.
| Capability | Against a remote entity |
|---|---|
| Filtering | Only as {{filter.<field>}} bindings you place in the template yourself, and only from a surface that populates them. Operators, AND/OR nesting and negation are not represented. A binding is a bare value. |
| Sorting | Not forwarded. ?order_by= is ignored. |
| Pagination | Not forwarded. ?limit=, ?offset= and cursors are ignored; no cursor is minted and no page block is returned. |
| Projection | Not forwarded. ?select= is ignored. |
| Relations / joins | Not available. ?include= resolves nothing, and an include of a remote entity from a local parent compiles SQL against a table that does not exist. |
| Aggregates | Not available. Aggregate clauses are discarded with the rest of the AST; the response is whatever the template returned. |
| Row cap | The 10,000-row default cap is never applied: dispatch returns before the engine injects it. The upstream response is returned whole, up to the 32 MiB body cap. |
| Count | count in the response envelope is the number of extracted rows, not an upstream total. |
Anything the upstream service should filter, sort or paginate by has to be passed explicitly in the
template, from a {{filter.…}} binding or a header.
Which surface you call from decides whether the filter bindings carry anything at all.
| Read surface | Filter bindings | Block dispatched |
|---|---|---|
GET /rest/{entity} | empty | getlist |
GET /rest/{entity}/id/{id} | empty | getlist |
POST /rest/{entity}/search | empty | getlist |
GraphQL list query with filter: | the filter argument | get when the filter pins the primary key, else getlist |
| GraphQL by-id query | id | get, when the entity’s primary key is named id |
PATCH /bulk?entity=…&filter=<k>=<v> | the repeated filter pairs | get when one pins the primary key, else getlist |
data.query, data.find_one, data.find_by_id from a script | empty | getlist |
Writes fare better, but not uniformly:
| Operation | What reaches the template |
|---|---|
| Create | The whole request body as {{payload.data.*}}; {{payload.id}} only if the body carried one. |
| Update | {{payload.data.*}} from the body, {{payload.id}} and {{filter.<pk>}} from the path id. |
| Delete | Nothing, the path id travels in the discarded mutation AST, so {{payload.id}} and {{filter.<pk>}} are both empty. |
| Restore | Nothing, for the same reason. The restore route sets neither payload nor filters, so the update block fires with every binding empty. |
Security
Section titled “Security”A remote entity is served by another service. The controls that apply to the call are enforced here; the controls that apply to the upstream’s rows are declared where the data lives.
| Control | On a remote entity |
|---|---|
Service allow-list (access.services) | Enforced before every call |
Per-action rules (access.actions) | Enforced before every call, deny-by-default like everywhere else |
| Row-level security on create | Enforced: the create-side rule checks the payload before the call |
| Row-level security on read, update, delete | Declared at the owning service. A remote entity that declares one fails the load. |
Field-level permissions (access.fields) | Declared at the owning service. A remote entity that declares them fails the load. |
Compliance masking (compliances:) | Declared at the owning service |
Each call carries the identity of the caller: {{user.id}}, {{user.tenant}} and {{user.roles}}
are the requesting user’s. Declare access.actions here to control who may reach the entity at
all, and the row-level and field-level protections on the owning side.
Failure and timeouts
Section titled “Failure and timeouts”Each request is capped by remote.timeout, defaulting to 10 seconds. The cap is wall-clock for the
whole exchange, applied by cloning the shared HTTP client per dispatch, so one entity’s timeout never
leaks into another’s. There are no retries and no circuit breaker: one operation is one attempt.
| Condition | Error |
|---|---|
| No block for the attempted operation | remote: entity "<name>" has no <op> operation |
Block present but no pre.template | remote: entity "<name>" <op> op has no template |
| Upstream status ≥ 400 | remote: upstream <status>: <first 512 bytes of the body> |
| Timeout, DNS failure, connection refused | remote: HTTP <method> <url>: <cause> |
post.expression invalid or failing | remote: extract: <parse|compile|eval> post.expression: <cause> |
The engine wraps each of these before the HTTP layer sees it, exec: remote query: … on a read,
exec: remote <op>: … on a write. That wrapped form is what the service log shows; the HTTP
layer strips the engine’s own prefixes and sends the client the cause alone.
Remote operations never report an affected-row count. The router returns -1 unconditionally,
regardless of what the upstream service said. The response envelope’s count is the number of rows
the expression produced.
Reaching a remote service from a script
Section titled “Reaching a remote service from a script”Scripts cannot make outbound HTTP calls, the httpGet, httpPost, httpPut and httpDelete
service functions are registered and callable but every one returns
call. The same is true of cacheGet/cacheSet/cacheDelete/cacheInvalidate, publish,
publishBatch, getState, getTransitions, canTransition, getStateHistory, config,
configMap, featureFlag, and the five content* functions.
A remote entity is the supported route. The
data.* namespace re-enters the engine, so calling
data.query or data.create on a remote entity dispatches through the router and runs the same
reduced chain, access rules included. Reads from a script always land on getlist with an empty
filter map, so a script that needs to address one upstream record has to reach it through a getlist
template that carries the discriminator in a header or a hard-coded path. The same applies to a
scripted endpoint: it reaches an upstream
service by reading or writing a remote entity, not by calling out itself.
Migrations and storage
Section titled “Migrations and storage”The migration planner skips every entity whose materialization is not table, so a remote entity
produces no DDL and no table exists for it. A bulk create on a remote entity sends each row through
the router, as a single create would. A relation eager-load into a remote entity compiles SQL against
a table that does not exist, so declare relations from a remote entity’s parents only where reads
do not include it.