0.39.1-M1 - Workspaces, Copilot, secrets and validation

The first milestone of 0.39, the biggest change Orbital has had. Multiple workspaces per instance, organisations, a secrets manager with five backends, a data validation framework, an MCP server, and a new multi-file code editor.

Available since 0.39.1-M1

0.39.1-M1 is the first milestone of 0.39 — the largest change Orbital has had. It has been in development since April, and it changes the shape of the product in a few significant ways.

The headline is workspaces: a single Orbital instance can now run many independent schemas, each with its own projects, its own compiled schema, its own connections and its own query context. Everything downstream of that — auth, secrets, search, history, caching, the language server — was reworked to be workspace-aware.

Alongside that: a secrets manager with five backends, a data validation framework, Copilot (chat-driven querying and schema building), an MCP server so external agents can query your data mesh, and a new code editor that edits whole projects rather than single queries.

Two features we announced over the summer also ship here for the first time: collection options (limit, offset, orderBy, uniqueBy on any query, pushed down to the source where it can take them), and @DeleteOperation / @SqlQuery for the database connectors.

As a milestone, 0.39.1-M1 is for early adopters and teams who want to test ahead of the full 0.39 release. Expect rough edges, and tell us about them.

Breaking changes

Orbital now requires a licenseBreaking change

Orbital checks for a license.json on startup. When you first sign in, Orbital contacts the license server at https://account.orbitalhq.app, downloads a free license and installs it. In most environments this needs no setup.

If no license is found, Orbital issues a short-lived fallback license so the server still starts — but that fallback only lasts 30 minutes, after which the UI shows a blocking overlay.

Orbital searches these paths in order, and the first valid license wins:

  1. --vyne.license.path (if set)
  2. ${vyne.app.data.path}/license.json — by default ./orbital_data/license.json
  3. ~/.orbital/license.json
  4. /opt/var/orbital/license/license.json

For air-gapped deployments that can’t reach the license server, Orbital supports an air-gapped mode that disables usage reporting. Get in touch if you need it.

See configuring your license for the full detail.

One improvement worth calling out: an expired license no longer terminates the JVM. The old LicenseMonitor called exitProcess(0) on expiry. That’s gone — expiry now surfaces in the UI and is governed by the license policy instead of killing the process.

The workspaces-enabled toggle is replaced by workspace-modeBreaking change

The boolean toggle is gone, replaced by a three-state enum:

vyne:  toggles:    workspace-mode: None    # None | Workspace | OrgAndWorkspace
ModeBehaviour
None (default)Single workspace. No workspace prefix in UI routes, no selector. Behaves as Orbital always has.
WorkspaceWorkspace routing. UI routes are prefixed /{workspaceId}/...
OrgAndWorkspaceOrg and workspace routing: /{orgId}/{workspaceId}/...

If you were setting workspacesEnabled: true, that key is now ignored and you will silently fall back to None. Set workspace-mode instead.

Query history tables gain non-null orgId and workspace_slug columnsBreaking change

A Flyway migration adds a workspace_slug column across eight history tables, adds orgId to query_error_event and trace_event, backfills both with default, and enforces NOT NULL. Indexes on (orgId, workspace_slug) are created alongside.

The migration runs automatically. On instances with a large query history, the SET NOT NULL statements take a table lock, so budget for some downtime on the first boot after upgrading.

The same migration drops the Postgres row-level-security policies on those tables. RLS was keyed on current_user, which never worked with a shared connection pool. Org and workspace isolation is now enforced in the application layer via org-membership checks and explicit orgId / workspace_slug filters on every query.

Rows written before this milestone cannot be attributed to a workspace — they were never stamped — so they stay as default/default.

The legacy Jet pipeline server has been removedBreaking changeRemoved

pipelines/pipeline-jet is gone from the build, along with the pipeline transports that only it used (S3, SQS, Cask, file-watcher, HTTP listener, JDBC, Kafka, Redshift, logging sink, polling query input) and the transform stage.

This is the standalone pipeline runner, which has been unmaintained and undocumented for a long time. Streaming queries are unaffectedstream { } queries run through pipelines/stream-engine, which stays.

H2 is no longer a shipped JDBC driverBreaking changeRemoved

H2DatabaseSupport has moved to test sources. The drivers shipped to end users are now Postgres, MSSQL, Oracle, Redshift, Snowflake and Databricks. If you had an H2 connection defined in connections.conf, it will fail to resolve.

Spring Boot 4 and Jackson 3Breaking change

Orbital is built on Spring Boot 4.0 and Jackson 3. If you write custom Orbital extensions — custom taxi functions, custom connectors, anything compiled against Orbital’s jars — you will need to migrate. The main things that bite:

  • Jackson 2 → 3: the mapper and builder are immutable, and several APIs were renamed. The annotation package is still com.fasterxml.
  • Spring 7 / Security 7 JSpecify nullability, and : Any generic bounds in Kotlin.
  • The Spring Boot 4 modular auto-config split (spring-boot-health, -jdbc, -hibernate, -mongodb, and the matching test slices).
  • Testcontainers 1.x → 2.x, if your tests use it.

Numeric comparison is now type- and scale-insensitiveBreaking changeFixed

Comparisons between numbers of different JVM types now coerce both sides to BigDecimal and compare with compareTo. Two consequences:

  • Cross-type numeric comparisons work. Previously only a narrow set of pairings (such as Int vs BigDecimal) compared correctly; others silently failed.
  • Equal and NotEqual are now scale-insensitive. 2.50 == 2.5 is now true where it was previously false, because BigDecimal.equals treats different scales as different values.

Taxi’s Long is backed by BigInteger, which previously had no branch in the arithmetic calculator at all and errored with “Unsupported number type”. Long arithmetic now works, and division on Long truncates — the same semantics as Int.

The right() function now reads from the rightBreaking changeFixed

right(input, n) was returning characters counted from the left of the string. It now returns the last n characters, as the name and documentation always said. If you worked around the old behaviour, remove the workaround.

Operation parameters are no longer matched to raw primitives by typeBreaking changeFixed

Given operation confirm(OrderId, note : String) and an OrderId in context, the old code would bind the OrderId’s value to note — because everything is assignable to String, so a raw primitive parameter matched whatever value happened to be nearby.

Parameters typed as raw primitives (String, Int, …) or Any are now populated only from an exact type match already in context — never an inherited match, never a graph query. Otherwise they resolve to null and normal nullability handling applies.

A default declared in the signature is unaffected, because it names the parameter it belongs to rather than being matched to it. That remains the supported way to populate a raw primitive.

If a schema was relying on the old behaviour, the operation will now be invoked with a null (or not invoked at all, if the parameter is required). Give the parameter a semantic type, or a default.

Query responses honour the Accept headerBreaking changeFixed

HTTP query responses now follow standard content negotiation, in this order:

  1. An explicit, specific Accept header wins.
  2. Otherwise the response type’s declared format (@Xml, @Csv) is used.
  3. Otherwise JSON.

Previously the model’s declared format always won, so a model annotated @Xml requested with Accept: application/json still came back as XML. If you have a client relying on that — sending an Accept header it didn’t mean and getting the model’s format regardless — it will now get what it asked for.

An absent Accept header is treated as “no preference” rather than being forced to JSON, so it defers to the model’s declared format. This applies uniformly to the /api/taxiql endpoints and to routed saved queries. Streaming responses (SSE and WebSocket) negotiate separately and are unchanged.

API routes are workspace-prefixed when workspace mode is enabledBreaking change

In Workspace or OrgAndWorkspace mode, all workspace-scoped APIs move under /api/{orgId}/{workspaceId}/..., and published query endpoints move to /api/{orgId}/{workspaceId}/q/....

In None mode — the default — legacy routes such as /api/schema/types and /api/q/myQuery continue to work exactly as before. This includes the anonymous-access security matcher, which now picks the pattern matching the active mode.

Smaller breaksBreaking change

  • The auth-token listing API changed shape. GET /tokens returned a flattened Map<ServiceName, List<AuthScheme>>. It now returns a row-shaped listing that preserves the package each entry came from, plus a sibling field carrying per-package parse errors.
  • Git credentials in workspace.conf are now secret references. Inline GitCredentials / GitSshAuth are replaced by a GitAuthentication reference that points at a secret name resolved through the workspace’s secrets manager at fetch time.
  • SOAP responses are parsed by JAXB element names. Responses are read using @XmlElement metadata and field access rather than by introspecting CXF’s generated Java getters. This fixes acronym and casing mangling (getSISOCode was becoming SISOCode rather than sISOCode), but if you had types hand-shaped to match the old mangled names, they now need to match the WSDL names.
  • UI routes renamed, with redirects in place: /stubs/local-environment, /catalog/diagram/mesh, /workspace/workspaces. The old query editor at /query/editor is replaced by /code-editor.
  • New privileges. LIST_SECRETS and EDIT_SECRETS were added and are granted to the admin and platform manager roles in the bundled default role definitions. Deployments that already have a roles.conf on disk will not pick these up automatically — Orbital only writes the defaults when the file is absent. Add them by hand if you want the secrets UI available to existing roles.

Workspaces and organisationsNew

A workspace is a named context that compiles to a schema. Everything schema-driven in Orbital — queries, streams, published endpoints, the catalog, the search index — operates within one.

Typical uses:

  • Team isolation — each team owns a workspace with its own services and types.
  • Environment separation — production, staging and development in one cluster.
  • Safe experimentation — test a new version of a service in a workspace without touching the others.

An organisation is the tenant above that. A cluster serves one or more organisations; on-prem deployments typically have one, and a default org is created on first boot if none exists.

Turning it on

vyne:  toggles:    workspace-mode: OrgAndWorkspace

When enabled, a combined org-and-workspace selector appears in the header. Collapsed, it shows the active workspace above the active organisation; expanded, it lists the workspaces in the org, with a nested Switch organisation submenu and management actions. In Workspace mode the org half is hidden.

Every page — catalog, code editor, projects, activity — is scoped to the active workspace, and the URL reflects it.

Where workspace config lives

Two things need storing: which orgs and workspaces exist, and what projects make up each workspace. Both are configured under vyne.orgs:

vyne:  orgs:    store: FILE                 # FILE | IN_MEMORY | DATABASE    config-path: ./orbital_data
BackendBest forWritable from the UI?
FILE (default)Local dev, single-node on-premYes
IN_MEMORYEphemeral environments, testsYes, until restart
DATABASEMulti-node and cloud deploymentsYes

The file backend reads an organisations.conf from config-path, with one workspace.conf per workspace underneath:

orbital_data/  organisations.conf  workspaces/    acme-bank/      production/workspace.conf      analytics/workspace.conf

The database backend stores the same HOCON content as text blobs in org_config, workspace_config_entry and workspace_config, using Spring Data repositories over Orbital’s existing datasource. Because the stored content is byte-identical to the file format, you can move between backends without transforming anything.

Workspace config also materialises back to disk on boot, so a cloud deployment with a populated database and a fresh disk rehydrates correctly. The file backend leaves hand-edited content alone.

What became workspace-aware

The bulk of the work in 0.39 was making everything downstream of the schema respect workspace boundaries. Things that used to be global singletons and are now per-workspace:

  • Connections and invokers. Every connection-based invoker (JDBC, Kafka, MongoDB, AWS, Azure, Hazelcast, SOAP, HTTP) is built per workspace through an InvokerFactory, from that workspace’s own connection registry.
  • services.conf and auth.conf. Service discovery and outbound auth resolve through per-workspace registries, so HOCON substitution sees that workspace’s own environment variables. Previously a single global registry meant auth.conf files discovered through a workspace’s project additionalSources were silently ignored, and outbound HTTP calls went out unauthenticated.
  • Hazelcast state. Operation caches, state stores and stream topics are all namespaced by workspace, so results can’t leak across boundaries.
  • The search index. Lucene indices are per workspace, stored under {basePath}/{orgId}/{workspaceId}/, rebuilt only for the workspace whose schema changed.
  • Query history. Every history table is stamped and filtered by org and workspace.
  • The language server. LSP sessions resolve the workspace’s live schema, and schema edits are delivered to the sessions bound to that workspace.
  • WebSocket streams. Schema notifications, query status, stream results and stub updates all reconnect when you switch workspace, and are filtered to the workspace you’re connected to.
  • Query routes, scheduled queries and persistent streams. These maintain cross-workspace indices and now subscribe to schema changes from every workspace, including ones created at runtime.

Workspaces created through the API or by dropping a directory on disk are registered live — no restart needed for schema compilation, search indexing or route registration.

Org-scoped authorizationNewSecurity

@RequiresOrgPrivilege replaces @PreAuthorize("hasAuthority(...)") across roughly 150 production endpoints. It resolves the request’s {orgId} path variable and delegates the decision to an OrgAuthorityService, which has two implementations:

  • The default is org-blind and checks the flat authority set, preserving behaviour for single-org and non-PropelAuth deployments.
  • When vyne.security.open-idp.roles.format=propelauth, per-org roles are read from the JWT’s org_id_to_org_member_info claim.

This unblocks users who belong to multiple organisations, who previously hit “You are a member of multiple organisations”.

Org membership is now enforced on the workspace endpoints themselves. Previously any authenticated user could list or create workspaces in any tenant by putting that tenant’s slug in the URL. Organisations are also auto-provisioned from JWT claims on first sign-in, so workspace creation works on a fresh deployment.

CopilotNew

Copilot is a chat interface to your data mesh. It runs in two modes:

  • Ask — natural-language questions answered by generating and running a TaxiQL query against the current workspace. “What’s Jim Patterson’s account balance?”, or “write me a query that joins orders to customers”.
  • Build — an agent with file-editing tools that works on your Taxi projects, streaming its edits directly into the code editor beside the conversation.

The mode is chosen before you send the first message, and pinned for the life of the conversation — reopening a conversation restores the mode it was started with.

Conversations are persisted with their history, can be renamed, and are listed in a sidebar. File uploads are supported and scoped to the workspace. In Build mode, edits stream into Monaco models as they arrive, so you watch the agent write.

Configuring it

Copilot is a separate service. Point Orbital at it and turn on the toggle:

vyne:  toggles:    copilot-enabled: true  copilot:    endpoint-url: http://localhost:9028    upload-store: local    local-store-path: ./orbital_data/copilot/uploads

Requests to the Copilot service carry the caller’s JWT, and all calls are scoped to the current org and workspace.

MCP serverNew

Orbital exposes a Model Context Protocol server, so Claude, Cursor, IDE agents and your own tooling can ask the same questions a Copilot user can.

vyne:  toggles:    mcp-server-enabled: true

One endpoint is mounted per workspace:

/api/{orgId}/{workspaceId}/mcp

Mounting it under the workspace path means it inherits the existing org-membership gate from the security filter chain. No tool takes orgId or workspaceId as an argument — the URL is the only source, so an MCP client can’t reach across into another tenant’s data by changing a parameter.

The server exposes a single ask tool:

FieldDescription
questionThe question, in natural language
executeRun the generated query and return rows, or just show the query that would run

It answers with the natural-language answer, the generated query, a queryHistoryId you can use to inspect the run in Orbital’s history, the result rows, and any queryError or diagnostics.

The tool description and the server’s MCP instructions are generated from the workspace’s own schema, so clients know what data they’re pointed at.

Authentication accepts a PropelAuth API key presented the standard way — Authorization: Bearer <token> — which is what MCP clients send. Orbital now discriminates structurally: a bearer credential shaped like a JWT is validated as one, anything else is validated as an opaque API key. Previously an API key had to be sent without the Bearer prefix, which MCP clients can’t do.

The new code editorNew

The old single-query editor is replaced by a multi-file editor at /code-editor.

  • A real file tree. It walks the project on disk, so taxi.conf, READMEs, connections.conf, nebula scripts and everything else is visible — not just .taxi files. Content is fetched lazily when you click. Per-file error counts show in the tree.
  • Split panels. Editors, query results and query history live in dockable panels you can arrange, and the layout persists across refreshes — keyed by workspace, so switching org or workspace doesn’t restore the wrong location’s panels.
  • File lifecycle. Right-click to rename or delete. Both flow through the same compile-after-change pipeline as content edits, with atomic moves for renames.
  • Save anything. Saving no longer runs every source in the package through the Taxi compiler and rejects the write on any error. Non-Taxi files are excluded from compilation entirely, and Taxi files with compilation errors save anyway, with the errors surfaced in the UI. The editor’s job is to write what you typed.
  • Language support is auto-detected from the file extension. Markdown renders. Unmapped types open as plain text instead of failing.

Two long-standing editor bugs are fixed. Editing or saving a file used to produce Symbol X is already declared errors for every symbol in the file, caused by the same file being registered under two different URIs — once through the language server and once through the schema editor’s in-memory edit application. And edits to files outside src/ were being nested under src/, while edits already addressed with a src/ prefix were double-nested at src/src/....

Secrets managementNew

Secrets are now first-class. Reference one anywhere in your config the same way you’d reference an environment variable:

connections.jdbc.warehouse {   url:      "jdbc:postgresql://warehouse.example.com:5432/prod"   password: ${secrets.DB_PASSWORD}}

Real values never enter the merged HOCON config. The placeholder resolves to an opaque token that the consumer resolves at the point of use — at connection time, at outbound-request time, at git-fetch time — so a secret lives in a call frame and never in long-lived state. Rotating a secret takes effect without a restart.

Backends

Pick one with vyne.secrets.backend:

ValueBackend
InMemory (default)In-process. Tests and demos only.
FileLocal file, age-encrypted (X25519 + ChaCha20-Poly1305). Dev only — the key sits next to the data.
InfisicalInfisical, cloud or self-hosted, via universal auth
AwsAWS Secrets Manager
VaultHashiCorp Vault KV v2
GcpGoogle Cloud Secret Manager

Each backend maps Orbital’s scope hierarchy onto its own naming rules — paths in Vault and Infisical, flat prefixed names in AWS, __-separated segments in GCP, whose names can’t contain slashes.

Scopes

ScopeVisible to
Serverevery workspace on this server
Organisationevery workspace in that organisation
Workspaceonly that workspace

Reads cascade — workspace, then organisation, then server — and the narrower scope wins on a name collision. There is no global namespace that bypasses scoping.

Managing them

Secrets are managed in the UI under Secrets, and over REST:

GET    /api/{orgId}/{workspaceId}/secretsGET    /api/{orgId}/{workspaceId}/secrets/{scope}POST   /api/{orgId}/{workspaceId}/secrets/{scope}PUT    /api/{orgId}/{workspaceId}/secrets/{scope}/{name}DELETE /api/{orgId}/{workspaceId}/secrets/{scope}/{name}POST   /api/{orgId}/{workspaceId}/secrets/refresh

Reads need LIST_SECRETS, writes need EDIT_SECRETS. No endpoint ever returns a secret value — responses carry name, scope, description, last-updated and version only. Server-scoped secrets are listable but operator-managed: writes to that scope are rejected with a 403.

A caller at /api/acme/prod/... can only touch Workspace(acme/prod) and Organisation(acme) — not another org’s or another workspace’s secrets.

Every backend is wrapped in a caching decorator with a 15-minute TTL (configurable via vyne.secrets.cache.ttl and .max-size), write-through invalidation, and the refresh endpoint above for forcing a reload after rotating a secret out of band.

Git authenticationNew

Adding a git project now captures credentials — none, username/password, token, or SSH key — as references to secrets rather than as credentials embedded in workspace.conf and travelling through git. The UI rejects plaintext entries at submit, since they would defeat the point.

Credentials resolve inside JGit’s configure(transport) call. SSH keys load as bytes, so no key is ever written to a temp file.

See managing secrets for the full setup.

Data validationNew

You can now declare validation rules against your types, and control what happens when data breaks them. Because rules are declared against types, you define them once and they’re enforced everywhere that type appears, whichever service the data came from.

Three parts: declare rules with annotations, activate them with @Valid, decide what happens with @OnValidationFailure.

import com.orbitalhq.validation.NotEmptyimport com.orbitalhq.validation.Valid
@NotEmptytype Name inherits String
@Validmodel Person {   name : Name}

A Person arriving as { "name": "" } produces one violation — rule com.orbitalhq.validation.NotEmpty, at path name, severity Error — and by default rejects the query with an HTTP 400. Remove the @Valid and the same data flows through untouched. Rules do nothing until something activates them.

The built-in rules

@NotEmpty, @MinLength, @MaxLength, @Pattern, @Min, @Max, @MultipleOf, @MinItems, @MaxItems, @UniqueItems, @Email, @Uuid. That’s feature parity with OpenAPI’s schema validations. Every rule takes an optional message and severity (Info, Warning or Error).

Rules can go on a type, a field, or a query parameter, and they’re inherited — a rule on Name applies to every type inheriting from it.

Two rule ids are produced by the engine rather than declared: NotNull, when a non-nullable field inside a @Valid scope holds a null, and ParseFailure, when a value can’t be parsed into its declared type.

Deciding what happens

@OnValidationFailure(action = ValidationAction.Warn, threshold = Severity.Error)query GetOrder {   find { Order }}
ActionBehaviour
RejectFails the request with an HTTP 400 and a structured violation report
WarnData passes through. Violations are still logged and reported
DropSkips the offending record and keeps going. Never silent — every drop is reported

threshold sets the minimum severity that triggers the action. All violations are reported regardless of threshold — the threshold only decides which are severe enough to act on. That pairs well with per-rule severity: mark rules you’re not yet confident about as Warning, watch them in your logs, promote them to Error once you trust them.

Defaults are per query kind: find rejects at Error, stream drops at Error. Query arguments always reject — dropping a record makes no sense when the record is the caller’s own input.

Violation paths are precise: cast.actors[1].actor.name, not just “somewhere in the response”.

Validation runs on query arguments, on data returned by services, and on streaming pipeline inputs.

See validating data and the rule reference.

Data sources

OracleNew

Orbital now ships an Oracle driver, with full write support: Insert and generated-primary-key Upsert use INSERT ... RETURNING, Update runs per-row batches, and a user-supplied-PK Upsert uses MERGE. Oracle’s MERGE can’t return rows, so the invoker echoes the input back, matching how the Postgres upsert behaves.

Table generation accounts for Oracle lacking CREATE INDEX IF NOT EXISTS and needing explicit lengths on indexed VARCHAR2 / CLOB columns. jOOQ’s dialect is resolved by probing the live connection rather than assuming a version, so Orbital doesn’t emit CREATE TABLE IF NOT EXISTS (23ai and later only) against an older database. The probe is cached per connection.

Metadata reads are scoped to the connecting user’s schema. Oracle has no schema concept separate from the user, so without that scoping Orbital crawled SYS, SYSTEM and every other user’s schema.

Database metadata is read directly, and scopedImprovedFixed

Table introspection — the connection UI’s table list, and the schema generator behind import from database — was built on SchemaCrawler, which required a full catalog crawl. On a warehouse that’s slow to the point of unusable, and it had no plugin support for Snowflake or Databricks.

It’s been replaced with targeted DatabaseMetaData calls. What that changes for you:

  • The reactive server no longer stalls. listConnectionTables and getTableMetadata ran their blocking JDBC calls on the Netty event loop, which starved the whole server until it was restarted. They now run on a bounded elastic scheduler.
  • Listing tables returns names only. Column, primary-key and index metadata were an extra round-trip per table, on a screen that only needs names. They’re opt-in now, via includeColumns / includeIndexes query parameters on the tables endpoint.
  • Generating Taxi for one table is independent of database size. It reads the tables you asked for plus the ones their foreign keys reference, one level deep, rather than crawling the whole schema.
  • Better type coverage. CLOB maps to String instead of failing generation outright, and Postgres domain types map to Any rather than erroring.

Databricks and SnowflakeNew

Databricks is new. Hostname, port, HTTP path and token are the user-facing parameters; AuthMech=3 and UID=token are baked into the URL template so you don’t see them.

Snowflake existed as a twelve-line shell that had never been used in anger, with its JDBC driver commented out of the build because the fat jar broke shading. It’s been rebuilt: region is now an optional URL parameter, and the thin driver replaces the fat one — no shading problems, no broken native build.

Neither platform supports the ON CONFLICT syntax the other drivers use for upserts, so both build a MERGE INTO statement instead. That needs something to match existing rows on, so when a primary key column isn’t among the written fields — a @GeneratedId key, for instance — the write falls back to a plain INSERT and logs a warning. It’s a warning rather than a silent fallback because rows can duplicate under an @UpsertOperation when it happens.

Driver-specific column unwrapping moved off the base JDBC invoker onto the driver itself, so Postgres owns its PGobject / PgArray handling and any driver returning its own wrapper types can be supported cleanly.

MongoDBNew

MongoDB connections can now be created and tested from the UI. The driver was already listed in the connections editor, but test and create posted nowhere — the UI had no URL mapping for NoSQL connectors and the backend had no endpoint. Both now exist, and MongoDB has a health-check provider like the other connectors.

Connection failures surface properlyFixed

A database-backed saved query invoked through a routed HTTP endpoint used to return 200 OK with an empty body when its connection was undefined or unreachable — indistinguishable from a query that legitimately matched nothing.

Connectors now raise typed exceptions whose HTTP status comes from the exception itself:

ConditionStatus
Schema references an undefined connection500
Connection defined but upstream unreachable502

JDBC connection failures are also reported as trace events, so they show up in the query profile rather than vanishing.

Deletes and native SQLNew

@DeleteOperation gives you type-safe deletes through TaxiQL. A single @Id renders WHERE pk IN (...), composite keys use row-value IN, and statements are chunked at 500 rows to stay under Oracle’s IN-list cap. Values are always bound, never inlined. The operation returns a deletedCount.

@SqlQuery lets you write the SQL yourself — joins, aggregations, dialect-specific statements — for the cases where the generated query isn’t what you want. One annotation covers reads and native DML, split by taxi’s write keyword. Reads map joined and aggregated rows onto plain models by column name or alias, with no @Table needed; writes return the affected-row count.

:param placeholders bind as real prepared-statement parameters, validated against the operation’s parameters before execution. Values never touch the SQL text.

Both were announced in July, and this is the first build they ship in.

Also

  • Overriding a service in services.conf can now downgrade from https to http, which it previously refused to do.

Query engine

Collection options: limit, sort, paginate and dedupeNew

You can shape the results of a query directly in TaxiQL — limiting how many rows come back, paginating through them, sorting them, and removing duplicates:

find { Person[]( CountryCode == "GB", orderBy: DateOfBirth desc, offset: 20, limit: 10 ) }

limit, offset, orderBy and uniqueBy are the standard database-style controls, and they work on any query, against any source — not just databases.

Sort by more than one field, and paginate:

find { Person[]( orderBy: [CountryCode asc, DateOfBirth desc], offset: 40, limit: 20 ) }

They work on projections too, including nested collections, which is handy for trimming child records per parent:

find { Customer[] } as {  name : CustomerName  // Only the 20 most recent transactions per customer  transactions : Transaction[]( limit: 20, orderBy: TransactionDate desc )}[]

Values can be literals or query parameters, so limit: maxRows works when maxRows is a query argument.

Pushdown is decided per option, per data source. When a source can apply an option itself, Orbital pushes the work down to it; when it can’t, Orbital does it after fetching. Either way you get the same answer, and pushdown only happens when it can’t change the result — if a source can’t sort, Orbital won’t push a limit below the missing sort, because limiting before sorting returns the wrong rows.

SourcePushed down
SQL databases (Postgres, MySQL, MSSQL, Oracle, Redshift, Snowflake, Databricks)limit, offset, and orderBy when every sort term resolves to a single column — rendered in your driver’s dialect
MongoDBlimit, offset and orderBy, as sort / skip / limit
Hazelcastlimit, as a key-ordered page
REST APIs, Kafka, everything elseNothing — Orbital applies all of it after fetching

uniqueBy is never pushed down; it’s always applied by Orbital. Operations annotated with @SqlQuery opt out of pushdown entirely, since options can’t be spliced into SQL you wrote by hand — they’re applied after the fetch instead.

On streaming queries, limit is supported and completes the stream once it has emitted N items. orderBy, offset and uniqueBy are rejected on streams, as is cursor pagination (after / before), which parses but is not yet implemented.

This was announced in July — 0.39.1-M1 is the first build it ships in. Full detail is in the collection options docs.

Operation parameters are gathered consistentlyFixed

Parameters were being gathered in four separate places that disagreed with each other: the mutation path, the graph-search path, the direct-invocation path, and again inside the invoker. The same call could bind a value to the wrong parameter on one path, send null on another, and ignore a declared default entirely on a third. An operation could also be invoked with fewer arguments than it declares, because the graph path caught any discovery failure and quietly dropped that parameter.

There is now one shared implementation returning an explicit map of parameter to value. Callers differ in what they do when it can’t be satisfied — direct invocation tries another candidate, graph search fails the edge, mutation returns a null — but they all gather the same way.

The operation-result cache is keyed off that map, too. Keyed off a set, as it was, getEligibility(orderId=A, lineId=B) and getEligibility(orderId=B, lineId=A) shared a cache entry.

Nullable parameters that can be constructed from context now are. Previously discover short-circuited to null the moment it saw a nullable parameter, so a nullable request body whose fields were all present in context was sent as null.

Circular type references no longer blow the stackFixed

A model whose definition leads back to itself — total : Total = (Total * 1.2), or a pair of types whose expressions reference each other — recursed until the JVM threw StackOverflowError.

A closed loop now yields a null carrying the cycle path — Order.total -> Order.total, or A -> B -> A — rather than throwing. Circular definitions are now legible, not valid; they remain unsatisfiable.

This matters more than it looks. StackOverflowError isn’t recoverable the way an exception is: if it lands while a class is initialising, that class stays unusable for the life of the JVM and every later touch throws NoClassDefFoundError. We hit exactly that during a benchmark run — it broke coroutine cancellation process-wide, with no error naming the cause.

Collecting arrays across collection boundariesFixed

A query like find { GivenName[] }, against a response whose givenNames sit two collection layers deep inside the returned object, failed with “no data sources can return GivenName[]”. The query graph only had scalar attribute edges, so there was no path.

There’s now an edge from each provided instance to every T[] reachable via a path crossing at least one collection field. Its cost sits between cheap attribute navigation and remote operation invocation, so a direct attribute path still wins when one exists, but the planner always prefers in-memory extraction over calling a service again.

A service returning no matching instances yields an empty T[] rather than failing the query.

Query errors reach the error viewFixed

Several places in the engine caught exceptions and returned a null or an empty result without telling anyone, so genuine failures never reached the query error view. Failures that are actually meaningful — a nullable operation parameter that couldn’t be constructed, unexpected exceptions while searching for a type — now publish to the error stream that feeds the errors websocket and query history.

Routine search backtracking is deliberately left silent. It’s normal control flow, it fires per row during projection, and surfacing it would flood the view with alarming noise.

Separately, fast-failing queries used to tear down their error stream before the UI could connect to it, so the same query would only intermittently show its errors. The error publisher keeps a 30-second replay buffer for exactly this reason; eviction is now deferred past the replay window so a late-connecting websocket can still drain it.

Also in the engineFixed

  • A TaxiQL statement that fails to compile returns 400 Bad Request rather than 500.
  • Failed searches capture where and why they failed, with a readable display of the failure point.
  • Graph search error reporting is more informative, and the error message now hints when a find with a contract would be better expressed as a given {}.
  • Fact-bag lookups had lost their combine, dedupe and absent semantics when they moved to an Either-based API: deep searches silently dropped all secondary-bag matches whenever the primary bag had any match, merged collections skipped deduplication, and “absent everywhere” was reported as an empty collection rather than a null — which stopped the object builder falling through to discovery. All three are restored.
  • Data policies are no longer applied to primitive types.
  • Projections no longer attempt to construct scalar types, and fields carrying expressions are deferred to expression evaluation rather than being built directly.
  • Object construction no longer throws when the type is nullable, matching field construction.

PerformanceImproved

This milestone adds a benchmark suite and CI guards rather than a broad optimisation pass. query-engine-benchmarks carries seeded synthetic schema and data generators, 13 workloads, 7 JMH micro-benchmarks, and a guard suite that gates merge requests on deterministic metrics — operation invocation counts, allocation budgets, complexity-class scaling ratios and heap-slope leak checks — while tracking wall-clock time as a nightly trend rather than a flaky gate.

One fix landed alongside it: fact-bag search caching now validates lazily on read against a generation stamp, so adding a fact is O(1) rather than doing predicate work proportional to every cached search. Measured 86–93% off addFact, with lookup cost flat.

A full review of the engine’s performance characteristics is written up internally. The remaining findings are the fix programme for subsequent releases, and each has a matching benchmark guard waiting to be switched on.

UIImproved

The navigation has been reorganised:

WasNow
Query editorCode editor — the new multi-file editor
Query historyActivity — running queries and history in one paginated view
AuthenticationSecrets
Stub ServersLocal environments
Catalog → Diagram tabMesh — promoted to a top-level item
Copilot

EndpointsNew

The endpoint page is now tabbed, matching the catalog layout:

  • Overview — query plan diagram and metrics
  • Schema — the model attribute tree for the query’s return type
  • OpenAPI — a generated OpenAPI spec, for non-streaming queries
  • Source — the query source

A new endpoint, GET /api/schemas/queries/{queryName}/openapi, generates the spec for any saved query by name. The existing /api/q/meta/{queryName}/oas only covered HTTP-routable queries.

Published query URLs shown in the UI now match where the endpoint is actually served — previously the raw declared URL was shown, which didn’t match the workspace-prefixed route.

Local environmentsFixed

Three unhappy paths on the Local Environments page used to fail silently or mislead:

  • Compilation errors. A nebula script that failed to compile produced no feedback at all — the Nebula server killed the websocket handler. Per-stack compilation errors are now accumulated and listed against their stack, clearing when a corrected version compiles.
  • Disconnected empty state. With nebula files defined but the server unreachable, the page claimed no environments were defined. It now shows “Waiting to connect…” with the names of the stacks it knows about from the schema.
  • Component start failures. An invalid DDL or similar now shows its message in the tree tooltip, as a banner on the component detail panel, and as a red Failed state in the status bar — which previously showed “Starting…” forever.

Two connection bugs are fixed too. A dropped session (a network blip, or a non-text control frame) used to kill the consumer coroutine permanently: the socket kept reconnecting and logging that it had connected, but nothing consumed the new sessions, so schemas were never resubmitted. And rapid status transitions were being dropped — the in-process HTTP component fires Starting and Running microseconds apart, and the sink was configured to drop an update for every subscriber if any one of them was momentarily at zero demand, leaving consumers stuck on “starting” forever.

Projects that reference NEBULA_* environment variables no longer flash a red config-errors banner at startup while the local stack is still coming up. Orbital distinguishes “Nebula starting” from a real config failure and shows a quiet “Starting local environment…” instead, escalating to a proper error if the connection drops.

Query graph debuggerNew

The old Angular graph-vis tool is replaced by a React Flow rewrite under tools/graph-debugger. Paste the graph the engine logs while planning a query and explore it interactively to work out why a query isn’t resolving.

Large graphs used to lock the canvas, so it now starts empty: add a single type, expand outward from any node via its off-canvas-neighbour badge, or drop the whole graph on at once for small ones. Auto-layout is left-to-right hierarchical, matching the engine’s traversal direction, and dragging a node pins it.

Smaller UI fixesFixed

  • Picking a result in the search bar navigated twice — once to the item you chose, and again to the first item in the list.
  • The schema diagram’s markdown round-trip now serialises member keys as qualified names, so saved layouts resolve on re-render.
  • Clicking a model node header in the schema diagram navigated to the literal path rather than the type’s qualified name.
  • Operation parameter type names link through to the catalog, matching the return type’s behaviour.
  • Query plans containing a Hazelcast node no longer throw.
  • Loading buttons inside pop-ups are no longer obscured by incorrect styling.
  • Alert action links (project errors, config errors, compilation errors) navigate to the correct workspace.
  • A 404 for a type that doesn’t exist no longer bounces you out to the workspace selector — only a genuine workspace-not-found does.

Other fixesFixed

  • HTTP header parameters were matched by position, not name. buildHttpHeaders resolved each @HttpHeader parameter by its index in the supplied list, assuming that list matched the operation’s declared parameter order. It doesn’t — directly-provided parameters are appended after searched ones — so header values could silently be attached to the wrong header.
  • A JDBC driver returning null threw an NPE rather than producing a null value.
  • Config loading could throw ConcurrentModificationException because custom types were being registered on every load rather than once at class initialisation.
  • An invalid PropelAuth API key returned 500. It now maps to 401 for a rejected token, 403 for a permitted token without access, and 503 when PropelAuth itself is unreachable.
  • A file watcher missed the contents of newly created directories. java.nio.WatchService doesn’t replay events from before a key is registered, so writing orbital/config/services.conf in one go meant the watcher saw only the orbital/ directory and never the files inside it. Creating a directory now registers the watch and walks its existing contents.
  • Sankey lineage rows weren’t stamped with the query’s workspace, so the lineage view in the query profile was empty for every workspace-scoped query.
  • History results for PROVIDED lineage nodes weren’t showing.
  • Trace events from published-query routes were stamped with the default workspace rather than the one that ran the query.

In case you missed it

Writing these notes turned up a gap in our own record-keeping. 0.36.0 (October 2025) and 0.38.0-M1 (June 2026) both shipped without a release announcement, and a handful of point releases went out quietly too. A fair amount of work reached you without us ever saying so.

None of this is new in 0.39 — it’s all been in your hands for a while. It’s here because you may not know you have it.

Custom Kotlin functionsNew

Available since 0.36.0

You can extend Taxi’s standard library with your own functions, written in Kotlin and loaded at runtime. Declare the function in Taxi, implement it in Kotlin, and it’s available in expressions, projections and queries like any built-in.

This is the extension point to reach for when a transformation can’t be expressed in Taxi itself — a proprietary check-digit algorithm, a domain-specific parse, an internal encoding scheme.

It’s documented at custom functions; it has simply never appeared in a changelog, because 0.36.0 never got one.

It’s also the mechanism the validation framework will use when user-written validation rules land — the loader is already proven.

Everything else

FeatureShipped inDocumented
Scheduled queries — run a query on a cron schedule0.36.0Yes
in / not in operators, pushed down to SQL and MongoDB0.36.0Yes
MongoDB native aggregations via @MongoAggregate0.36.0No
map { } iterates a collection rather than operating on it whole0.36.0No
Negate (!) and modulo operators0.36.0No
env.conf resolves environment variables inside annotations0.36.0Partly
Query-level Kafka offset override, by annotation0.36.0Yes
Custom error responses thrown from a given { } clause0.36.0No
Query error events persisted to the database0.36.0No
Roughly twenty stdlib functions — append, average, joinToString, ifEmpty, orEmpty, size, isNullOrEmpty, indexOfItem, containsAll, containsAny, all, some, none, emptyInstance, startsWith, endsWith, matches, containsPattern, padStart, padEnd, applyFormat0.36.0No
Logbook HTTP request/response capture0.36.0No
File-system monitors filter irrelevant files, stopping continuous polling loops0.36.7n/a
WebSocket transport for schema publication0.37.0-M4Partly
Schema-publisher Spring Boot starter0.37.0-M4Yes
SOAP as a loadable language via additionalSources0.38.0-M1Yes
XSD as a source format0.38.0-M1Yes
JDBC batchingbatchSize / batchDuration on database writes0.38.0-M1Yes
Operations usable as callable expressions0.38.0-M1No
Multiple authentication tokens per service0.38.0-M1Yes
Environment-specific config files (auth.conf overridden per environment)0.38.0-M1Partly
New query-plan diagram0.38.0-M1No
Tabbed catalog layout, and README as the first tab in the project explorer0.38.0-M1n/a
Endpoint for submitting parameterized query objects0.38.0-M1No
JSON message body accepted on the SSE streaming endpoint0.38.0-M1No
Git configuration changes clean up stale clones0.38.0-M1n/a
schema-management relicensed to Apache 2.00.38.0-M1n/a

The undocumented rows are on our list. If one of them is something you’ve wanted, tell us and we’ll bump it.

Upgrading

This is a milestone build. Try it somewhere that isn’t production first.

  1. Read Breaking changes — particularly the license requirement and the workspace-mode toggle rename.
  2. Back up your query history database. The Flyway migration takes table locks to enforce the new non-null columns.
  3. If you use H2 as a data source, migrate off it before upgrading.
  4. If you rely on the standalone Jet pipeline server, stay on 0.38.x. Streaming queries are unaffected.
  5. Start Orbital and sign in. Your free license downloads automatically.

Workspaces are opt-in. Leave workspace-mode at None and Orbital behaves as it always has, with a single workspace and legacy routes intact — you can adopt workspaces when you’re ready.