OQL query language
A typed, ACL-aware query language for tickets - filters, sorting, functions, and a JQL compatibility mode.
OQL is a typed, ACL-aware query language for tickets. It's the canonical way to express filter combinations that the per-entity list endpoints (/projects/:id/tickets?status=…&assignee=…) can't reach without growing yet another querystring parameter.
OQL also accepts JQL syntax through a thin compatibility adapter - paste a JQL query, get the same result.
At a glance
project = ACME AND assignee = currentUser() AND statusCategory != done
ORDER BY priority DESC, dueDate ASC
LIMIT 25- Endpoint:
POST /query(cursor-paginated, rate-limited at 60/min/user). - Surface: REST endpoint, MCP tool
orboto_query, CLI shortcutorboto query "<oql>", ⌘K palette OQL toggle. - Authorisation: every query starts from a project-membership join + private-ticket guard. External users get
is_private = falsehard-pinned regardless of how the OQL is shaped.
Writing your first query
If you've never written OQL before, build it up one clause at a time rather than reading the grammar cold. Every example below is real and runnable through any surface.
Start with one condition. A query is just field operator value:
assignee = currentUser()This alone returns every ticket assigned to you, across every project
you're a member of - no LIMIT, no ORDER BY, nothing else required.
currentUser() is a function call that resolves to your own user id at
query time, so the same query text means "my tickets" for whoever runs
it.
Add a second condition with AND. Combine conditions the way you'd
expect from any query language:
assignee = currentUser() AND statusCategory != doneNow it's your tickets that aren't finished yet - != excludes rather
than includes, and statusCategory is the four-value workflow bucket
(todo / in_progress / in_review / done) rather than a
project-specific status name, so this works the same regardless of how
any individual project labels its statuses.
Sort the results. ORDER BY goes after every filter condition, and
takes one or more fields with an optional direction (ASC is the
default when you omit it):
assignee = currentUser() AND statusCategory != done
ORDER BY priority DESC, dueDate ASCThis reads as "my open work, most urgent priority first, and for ties on priority, whichever is due soonest."
Cap how many rows come back. LIMIT goes last:
assignee = currentUser() AND statusCategory != done
ORDER BY priority DESC, dueDate ASC
LIMIT 10Scope to a project, and search text. Add project = <key> to
narrow to one project, and ~ for a case-insensitive substring match on
a text field:
project = ACME AND title ~ "timeout" AND statusCategory != doneCheck for something being missing. IS EMPTY / IS NOT EMPTY on a
multi-valued field, and IS NULL / IS NOT NULL on a scalar one:
assignee IS EMPTY AND statusCategory = todoThat's the whole shape of the language: pick fields from the
field reference below, combine them with AND /
OR / NOT and parentheses for grouping, optionally sort and limit.
Everything past this point is reference material for the specific
fields, functions, and edge cases available to you.
Grammar
Formal PEG grammar lives in apps/api/src/services/oql/grammar.peggy. Informally:
query = expression (ORDER BY orderList)? (LIMIT integer)?
expression = orExpr
orExpr = andExpr (OR andExpr)*
andExpr = notExpr (AND notExpr)*
notExpr = NOT? predicate
predicate = "(" expression ")"
| field operator value
| field "IN" "(" valueList ")"
| field "IS" ("NOT")? ("NULL" | "EMPTY")
operator = "=" | "!=" | "<" | "<=" | ">" | ">=" | "~" | "!~"
value = stringLit | numberLit | dateLit | functionCall | identifier
functionCall = "currentUser()" | "now()" | "startOfWeek()" | "endOfWeek()"
| "startOfMonth()" | "endOfMonth()" | "daysAgo(n)"- Keywords (
AND,OR,NOT,IN,IS,NULL,EMPTY,ORDER,BY,LIMIT,ASC,DESC) are case-insensitive and word-bounded -andrewis an identifier, notAND+rew. - Strings:
"double"or'single'quotes. Backslash-escapes\"and\'work. - Identifiers:
[a-zA-Z0-9_][a-zA-Z0-9_-]*- the trailing dashes letACME-42parse as a bareword, and the leading-digit class lets digit-leading project keys like10Mor3Dparse without quoting. Pure-digit tokens still parse as numbers becauseNumberLitis tried first in value position. - Dates: ISO format
"YYYY-MM-DD"as a quoted string. Function calls return either ISO timestamps (now(),daysAgo(n)) or strings (currentUser()→ user UUID). - Empty input is valid and matches every visible row (subject to ACL).
Field reference
Grouped by what the field describes, so you can jump straight to the
category you need. Every field accepts the operators listed; anything
else returns 400 with errorKey: errors.oql.unknown_field.
Identity
| Field | Type | Operators | Notes |
|---|---|---|---|
key | string | = != < <= > >= ~ !~ | Ticket key, e.g. ACME-42. |
ticketNumber | number | = != < <= > >= ~ !~ | Integer half of ticketKey (ACME-42 → 42). Use this - not key - when sorting numerically, otherwise ACME-10 lands before ACME-2. |
title | string | = != ~ !~ | Ticket title. ~ is case-insensitive substring match (ILIKE). |
description | string | = != ~ !~ | Ticket description body. COALESCEd to empty string so ~ matches NULL rows correctly. |
Status, type, priority
| Field | Type | Operators | Notes |
|---|---|---|---|
status | enum | = != ~ !~ | Legacy lifecycle enum (TODO / IN_PROGRESS / IN_REVIEW / DONE / WONT_FIX). Backed by tickets.status, kept in sync with the FK status's category. |
statusName | string | = != ~ !~ | The display name of the ticket's current ticket_status row (e.g. "In Progress"). Use this for projects with custom workflow names. |
statusCategory | enum | = != | Workflow bucket: todo / in_progress / in_review / done. |
priority | enum | = != | blocker / high / normal / low / trivial. |
priorityLevel, severityLevel | number | = != < <= > >= ~ !~ | Numeric mapping of priority: blocker=5, high=4, normal=3, low=2, trivial=1. Higher = more important. Use for priorityLevel >= 4 ("high or blocker") and ORDER BY. Both names accepted. |
type | enum | = != | task / bug / story / epic. |
People
| Field | Type | Operators | Notes |
|---|---|---|---|
assignee | identifier (multi) | = (membership) · IN (…) · IS [NOT] EMPTY | Resolves by UUID, exact email, OR case-insensitive full name. IS EMPTY = unassigned. |
reporter, createdBy | identifier | = != | tickets.created_by. Both names accepted. Resolves by UUID, exact email, OR case-insensitive full name. A value matching no user returns an empty result set (never an error); ambiguous names match any user with that name. |
reporterType | enum | = != | guest / internal - is the ticket's creator an external (guest) account? reporterType = guest pulls up everything filed by guests without listing each guest's email. |
assigneeType | enum | = != | guest / internal - guest when the ticket has at least one external assignee; internal is the exact complement (all-internal or unassigned). |
Labels, dependencies, git
| Field | Type | Operators | Notes |
|---|---|---|---|
labels | identifier (multi) | = (membership) · IN (…) · IS [NOT] EMPTY | Match by label name. |
dependsOn, blockedBy | identifier (multi) | = (membership) · IN (…) · IS [NOT] EMPTY | Match the depended-on ticket by UUID or ticketKey. IS EMPTY = no dependencies. Both names mean the same direction (A depends on B = A is blocked by B). |
hasGitActivity | boolean | = != | True when the ticket has any linked commit, PR, or issue event in git_activities. |
waitingForGitIngestion | boolean | = != | True when the ticket is in_review, has zero git_activities rows, AND the project has an active git connection - a stalled-ingestion signal distinct from a ticket that's simply not linked to any commit. |
Organization
| Field | Type | Operators | Notes |
|---|---|---|---|
milestone | string | = != · IN (…) | Milestone key (ACME-M3), display name, or UUID. Use the key when names collide. |
version | string | = != | Version display name. |
project | string | = != | Project key (e.g. ACME) or display name. |
parentKey | string | = != | Parent ticket's key - useful for walking an epic into its children. |
isPrivate | boolean | = != | true / false. |
Dates
| Field | Type | Operators | Notes |
|---|---|---|---|
dueDate, startDate, closedAt, createdAt, updatedAt | date | = != < <= > >= ~ !~ | ISO date strings or function values like daysAgo(7). |
Effort and time
| Field | Type | Operators | Notes |
|---|---|---|---|
estimatedTimeMinutes | number | = != < <= > >= ~ !~ | Integer minutes. |
loggedMinutes | number | = != < <= > >= ~ !~ | Computed via correlated SUM over time_entries; expensive on huge time tables. |
progress, donePercent | number | = != < <= > >= ~ !~ | loggedMinutes / estimatedTimeMinutes * 100. NULL when estimate is missing or zero - progress IS NULL finds unestimated tickets. Uncapped: values > 100 mean "over budget". Both names accepted. |
Function reference
| Function | Returns | Notes |
|---|---|---|
currentUser() | string (UUID) | The requester's user id. |
now() | ISO timestamp | Wall-clock at translate time. |
startOfWeek(), endOfWeek() | ISO timestamp | Monday-start week, UTC. |
startOfMonth(), endOfMonth() | ISO timestamp | UTC month boundary. |
daysAgo(n) | ISO timestamp | n whole days before now(). |
The function whitelist is closed - anything else (pg_sleep(), version(), current_user, …) is rejected at translate time.
Operators
=,!=- equality. For multi-valued fields (assignee,labels) this is membership.<,<=,>,>=- ordering on numbers and dates.~,!~- case-insensitive ILIKE. Pattern is automatically wrapped in%…%.IN (a, b, c)- equality against any of the listed values.IS NULL/IS NOT NULL- null checks on scalar fields.IS EMPTY/IS NOT EMPTY- multi-valued field has zero / non-zero rows.
ORDER BY + LIMIT
… ORDER BY priority DESC, dueDate ASC
… LIMIT 25Multiple sort keys allowed. Direction defaults to ASC when omitted. The route appends tickets.id as a tie-breaker so cursor walks don't stutter.
LIMIT caps the page size at the source. The route also clamps to 200 - anything larger is silently capped.
Cookbook
# My open work, blockers first
assignee = currentUser() AND statusCategory != done
ORDER BY priority DESC, dueDate ASC
# Tickets I touched this week
assignee = currentUser() AND updatedAt >= daysAgo(7)
ORDER BY updatedAt DESC
# Anything blocking ORB this week
project = ACME AND priority IN (blocker, high) AND dueDate <= endOfWeek()
# Unowned bugs in the backlog
labels = bug AND assignee IS EMPTY AND statusCategory = todo
# At-risk for budget - logged exceeds estimate
loggedMinutes > estimatedTimeMinutes AND statusCategory != done
# Children of an epic
parentKey = ACME-200 ORDER BY ticketKey ASC
# Tickets shipped in the last sprint
statusCategory = done AND closedAt >= startOfMonth() AND closedAt < endOfMonth()
# Stale review queue - sitting in review for 3+ days
statusCategory = in_review AND updatedAt <= daysAgo(3)
# All my assigned bugs in the current quarter
assignee = currentUser() AND labels = bug AND createdAt >= startOfMonth()
# Free-text contains in a ticket key prefix
key ~ "ACME-2"
# Everything filed by guests across my projects
reporterType = guest ORDER BY createdAt DESC
# Open tickets that have a guest assignee
assigneeType = guest AND statusCategory != done
# All guest-related tickets (reported by OR assigned to a guest)
reporterType = guest OR assigneeType = guestJQL compatibility
Pass syntax: 'jql' (or --syntax=jql from the CLI) to accept JQL-flavoured input. The adapter pre-rejects unsupported tokens then translates the survivor through the OQL parser with field/value renames applied.
Field aliases (JQL → OQL)
| JQL | OQL |
|---|---|
due | dueDate |
created | createdAt |
updated | updatedAt |
resolved | closedAt |
resolution | statusCategory |
sprint | milestone |
issuetype | type |
Value aliases on resolution / statusCategory
| JQL value | OQL value |
|---|---|
Done | done |
Unresolved | todo |
"In Progress" | in_progress |
Unsupported features (and what to do instead)
| JQL feature | Suggested OQL |
|---|---|
WAS | updatedAt >= daysAgo(30) (we don't track historic field values) |
CHANGED | updatedAt >= daysAgo(7) |
issueHistory() | None; the audit log surface is the closest analogue. |
votes | priority or labels for marking importance. |
workRatio | loggedMinutes > estimatedTimeMinutes. |
attachments | Not queryable; surfaced on the ticket detail. |
originalEstimate | estimatedTimeMinutes (orboto only stores the current estimate). |
cf[…] | Not supported - orboto has no custom-field schema. |
Each unsupported token produces a 400 with errorKey: errors.jql.unsupported_feature carrying a suggestion field the UI surfaces verbatim.
Endpoint
POST /query
Authorization: Bearer <token>
Content-Type: application/json
{
"oql": "project = ACME AND assignee = currentUser()",
"syntax": "oql", // or "jql" - defaults to "oql"
"cursor": "...", // opaque, from a previous response.nextCursor
"limit": 25 // 1-200, default 25
}Response:
{
"items": [Ticket, ...],
"nextCursor": "opaque-token-or-null",
"queryPlan": { "sql": "...", "params": [], "ast": {...} } // ?explain=true, super-admin only
}Errors
All 4xx errors follow the ErrorResponseSchema shape (error, errorKey, errorParams):
| Status | errorKey | When |
|---|---|---|
| 400 | errors.oql.parse | Syntax error. errorParams carries line, column, expected, message. |
| 400 | errors.oql.unknown_field | Field not in the whitelist. |
| 400 | errors.oql.unknown_function | Function not in the whitelist. |
| 400 | errors.oql.unsupported_operator | Operator can't be applied to that field. |
| 400 | errors.oql.invalid_value | Type coercion failed (e.g. malformed date, enum value not in allow-list). |
| 400 | errors.oql.wrong_arity | Function called with the wrong number of arguments. |
| 400 | errors.jql.unsupported_feature | JQL adapter rejected an unsupported token. errorParams carries token + a suggestion. |
| 401 | errors.common.unauthorized | Missing / invalid bearer token. |
| 429 | errors.common.rate_limited | More than 60 calls / minute / user. |
?explain=true
Super-admin only. Adds queryPlan: { sql, params, ast } to the response so an operator can debug "why does my OQL return 0 rows" without database access. Non-admins receive the same 200 response without the field - no leak that the param exists.
Troubleshooting
| Symptom | Likely cause |
|---|---|
errors.oql.unknown_field on a field you're sure exists | Check spelling and case - fields are case-sensitive (statusCategory, not StatusCategory). Confirm it's in the field reference above; custom fields aren't supported (see Roadmap). |
| Query parses but returns 0 rows unexpectedly | Authorisation is applied before your filter (see below) - you only ever see tickets your account can already see. As a super-admin, add ?explain=true to inspect the generated SQL. |
ACME-10 sorts before ACME-2 | Sort on ticketNumber, not key - key sorts as text. |
| A date comparison silently matches nothing | Dates must be quoted ISO strings ("2026-01-15") or a function call (daysAgo(7)) - a bare unquoted date is parsed as an identifier and fails field lookup. |
JQL input rejected with errors.jql.unsupported_feature | The adapter has a closed alias list (field aliases, unsupported features). The error's suggestion field names the OQL equivalent to use directly. |
errors.common.rate_limited | More than 60 calls/minute for the user. Batch reads with a wider LIMIT and cursor pagination instead of polling in a tight loop. |
Authorisation
The translator embeds the auth pipeline into the WHERE clause as the FIRST predicate, in this exact order:
- Project membership -
EXISTS project_members WHERE user_id = :requester. Super-admin bypass collapses toTRUE. - Private-ticket visibility -
is_private = false OR EXISTS ticket_access_acl OR has-global ticket:view_private. - External-user hard pin - when the user row has
is_external = true,is_private = falseis appended LAST. No OQL clause can widen the filter past this guard.
Every query - * SELECT, IS NULL, OR-of-everything - runs with this predicate AND-ed in. The translator's security tests assert this contract.
Surfaces
| Surface | How |
|---|---|
| REST | POST /query (this doc). |
| CLI shortcut | orboto query "<oql>" [--syntax=oql|jql] [--limit=25] [--cursor=...] [--explain] - see the CLI daily workflow. |
| MCP tool | orboto_query - same input shape, structuredContent envelope mirrors the REST response. |
| ⌘K palette | OQL toggle next to the AI toggle. Errors render inline with line/column. "Save as bookmark" persists the OQL via saved_searches.oql. |
| Saved searches | saved_searches.query_type ENUM ('legacy', 'oql', 'jql') + saved_searches.oql TEXT. New bookmarks are oql/jql; old structured bookmarks keep working under legacy. |
| AI search bridge | /search/nl returns the equivalent OQL string in response.oql - the palette shows it under the chip row with an "Edit in OQL" button that pre-fills the OQL input. |

Performance notes
- The translator is pure-function and < 1 ms per call. The compiled peggy parser caches at module-load.
loggedMinutesuses a correlated SUM overtime_entries; on workspaces with millions of time entries this slows queries that filter on it. A materialised view is the planned escape hatch when the slow-query threshold trips.- ORDER BY on text fields (
title,keyvia~) doesn't use the search-vector index - keep complex text searches on the dedicated/searchendpoint and use OQL for the structured filter combinations. - The cursor is
(updated_at, id)tuple-comparison so default-ordered queries paginate efficiently. Custom ORDER BY queries pay an in-memory sort if the order doesn't match an existing index.
Roadmap
Out of scope for v1 (intentionally):
- Multi-entity queries (
FROM tickets, comments). - Aggregation (
COUNT,SUM,GROUP BY) - the analytics endpoints already cover those. - Update queries (
UPDATE tickets SET status = ...) - too dangerous for a public DSL. - Custom-field schema - not in the data model.
- Free-text body search - stays on
/search. - Query history / performance insights.
If any of these become hard requirements, let us know.