Filtering
Most List endpoints accept a filter parameter: a single expression that narrows the results
server-side. qibdo Cloud implements the
Google AIP-160 filter language consistently across all services,
so the same syntax works everywhere it is offered. A few listings narrow differently because the
data behind them is not tabular, and each says so on its own service page. See
Where filtering does not apply.
Pass the expression as the filter query parameter (URL-encoded over REST), alongside
page_size, page_token, and order_by:
curl --get https://api.qibdo.example.com/taxonomy/v1/organisations/$ORG/groups \ --header "Authorization: Bearer $QIBDO_API_TOKEN" \ --data-urlencode "filter=name : 'eng' AND create_time >= '2026-01-01T00:00:00Z'"An empty filter matches everything.
The filterable surface is curated
Section titled “The filterable surface is curated”Each resource considers an enumerated set of fields, not everything in the response. AIP-160 allows a service to consider a subset, and requires it to document which. A field in the set is always accepted; a field outside it is always rejected. There are no hidden fields to discover by trial.
Two places list the set for a resource:
- Its page in the service’s API reference, for example the compute reference, which shows the filterable and sortable fields on every List operation.
- The discovery endpoint, at runtime. See Discovering the surface.
Field names are the resource’s wire names in snake_case, the same names you see in responses.
Where a value is stored internally is an implementation detail and is not part of the contract.
Where filtering does not apply
Section titled “Where filtering does not apply”A listing narrows differently when the data behind it is not a set of records with indexed attributes. A field predicate would be misleading there, or ruinously expensive, so it is not offered. Two shapes exist today, and each is documented on the service page that owns it:
- Ordered by key. Object storage listings take
prefixanddelimiter, because a bucket is a flat keyspace rather than a table. See buckets and objects. - Owned by another query language. Some telemetry listings are enumerated by the store that holds them, and are narrowed with LogQL, PromQL or TraceQL. See querying logs, metrics and traces.
A listing may also accept a restricted dialect rather than the full language. Registry artifacts
are filtered through the registry engine, which accepts three fields with = and AND only. The
resource’s reference page and the discovery endpoint are always authoritative for what it accepts.
Grammar
Section titled “Grammar”The filter string is a concrete grammar layered on top of AIP-160: AIP-160 defines the
filter field as the API surface, and qibdo fixes the exact syntax that goes inside it. That
syntax is a strict subset of the
official AIP-160 EBNF. The grammar
qibdo’s parser accepts is:
filter = expression | empty ;
expression = and_expression ;
and_expression = or_expression { AND or_expression } ; (* OR binds tighter than AND *)
or_expression = unary_expression { OR unary_expression } ;
unary_expression = ( NOT | "-" ) unary_expression | primary ;
primary = "(" expression ")" | correlated_call | function_call | presence | comparison ;
presence = field ":" "*" ; (* unquoted star, only here *)
comparison = field comparator value | field ; (* a boolean field alone means = true *)
function_call = field "." IDENTIFIER "(" value ")" ; (* exactly one argument *)
correlated_call = field "." "any" "(" "(" expression ")" ")" ;
field = IDENTIFIER { "." IDENTIFIER } ; (* max 3 segments *)
comparator = "=" | "!=" | ">" | ">=" | "<" | "<=" | ":" ;
value = STRING_LITERAL | NUMBER_LITERAL | BOOLEAN_LITERAL | IDENTIFIER ;
STRING_LITERAL = "'" { char | "''" } "'" (* single-quoted, '' escapes ' *) | '"' { char } '"' ; (* double-quoted *)
NUMBER_LITERAL = [ "-" ] DIGIT { DIGIT } [ "." DIGIT { DIGIT } ] ;
BOOLEAN_LITERAL = "true" | "false" ;
IDENTIFIER = LETTER { LETTER | DIGIT | "_" } ;
AND = case-insensitive "AND" ;OR = case-insensitive "OR" ;NOT = case-insensitive "NOT" ;Whitespace around operators is flexible. The sections below walk through each production.
Operators
Section titled “Operators”Comparison
Section titled “Comparison”| Operator | Meaning | Example |
|---|---|---|
= | equals | status = "QIBDO_VM_STATUS_RUNNING" |
!= | not equals | hypervisor != "KVM" |
> >= | greater than (or equal) | cpu.v_cpus_boot >= 8 |
< <= | less than (or equal) | cpu.v_cpus_boot < 32 |
: | has (see below) | cpu.features.name : "avx2" |
Whether capitalisation matters depends on the operator, not on the field. Searching inside a value ignores it; selecting a value does not.
| You write | Capitalisation |
|---|---|
= != > >= < <= | matters |
: on a string, and quoted * patterns | ignored |
contains, startsWith, endsWith | ignored |
So name : "storage" and name : "Storage" return the same rows, while
name = "Storage-Viewer" does not find a resource stored as storage-viewer.
Equality stays exact deliberately. Names are unique within their parent, and that uniqueness is
case-sensitive — so a case-insensitive = could match two different resources at once, and a
lookup by name would stop being a lookup.
Fields that hold one of a fixed set of values, such as a status or an engine, behave differently again: see Fixed sets of values below.
The : operator
Section titled “The : operator”: is the has operator. It has three meanings, chosen by the shape of the field it is applied
to. This is one operator, not three: the field decides.
| Field shape | : means | Capitalisation | Example |
|---|---|---|---|
| Scalar string | substring match | ignored | os.cmdline : "console" |
| Repeated (a list) | element containment | matters | cpu.features.name : "avx2" |
| Nested structure | containment | matters | os.boot_devices : "NETWORK" |
Containment is an exact match against each element, not a search inside one, which is why it keeps capitalisation: the elements are values, not text to scan.
The rule that follows from this, and the one worth remembering:
A repeated field is matched with
:. A scalar field is matched with=.
Using = on a repeated field does not match “the list equals this value”, and using : on a
number or boolean is an error. When a field is repeated, the discovery endpoint says so explicitly
through repeated_field.
Containment preserves the value’s type, so a number matches a stored number rather than its text form. Negation works as it does everywhere else:
cpu.features.name : "avx2"-cpu.features.name : "avx512f"cpu.features.name : "avx2" AND cpu.v_cpus_boot >= 8Fixed sets of values
Section titled “Fixed sets of values”Some fields hold one of a fixed, published set of values — a status, an engine, a resource kind. You select one of those values; you do not search inside them. Two things follow.
The value resolves whatever case you write it in. status = "qibdo_vm_status_running" and
status = "QIBDO_VM_STATUS_RUNNING" are the same request. A value that is not in the set is
rejected outright, listing the values that are — it does not quietly return an empty page:
INVALID_ARGUMENTThe filter value 'RUNNIG' could not be parsed for property 'status'A substring operator is refused, not attempted. status : "RUNNING" is an error naming the
operator to use instead and listing the legal values, because a partial match against a closed set
has no meaning the API can honour:
INVALID_ARGUMENTThe field 'status' holds one of a fixed set of values;select one with '=' rather than searching it with ':'The discovery endpoint tells you which fields these are before you write a filter: such a field
lists its valid_values, and its operators do not include :. See
Discovering the surface.
Asking about one element of a list
Section titled “Asking about one element of a list”A condition on a repeated field asks whether some element matches. Two such conditions therefore ask two independent questions, and the elements that satisfy them need not be the same one.
That distinction decides real answers. A storage class carries a list of per-region availability entries, so:
# "some entry is in this region" AND, separately, "some entry is available"availability.region_id = "us-east-1" AND availability.status = "…AVAILABLE"matches a storage class whose entry for us-east-1 is unsupported while a different entry, in
another region, is available. Offered as a choice, it would fail on use.
To require that a single element satisfies several conditions at once, name the field and call
any:
# one entry that is both in this region and availableavailability.any((region_id = "us-east-1" AND status = "…AVAILABLE"))The inner parentheses are part of the grammar: the argument is a parenthesised expression, and
any(x = 1) is not accepted. Inside the call, field names are written without the leading path —
region_id, not availability.region_id — because the call has already named it; the fully
qualified form is accepted too. Naming a field the list does not carry is refused, and the error
lists the ones it does.
What comes back:
- The whole resource, unpruned. The filter chooses which resources match; it never edits them. A returned storage class carries its complete availability list, exactly as an unfiltered request would return it.
- Each resource at most once, and the reported total counts resources, not elements.
- Negation applies to the whole question.
-availability.any((…))means no element satisfies it, never “some element fails it”. A resource whose list is empty therefore matches the negated form and no positive one.
Not every repeated field accepts any. The ones that do are listed by the discovery endpoint as
correlatable fields.
Presence and absence
Section titled “Presence and absence”To ask whether an optional field is set at all, use the unquoted star. It is valid only on the
right of :, and nowhere else in the grammar:
numa.mode:*-numa.mode:*| Form | Meaning |
|---|---|
numa.mode:* | the field is set |
-numa.mode:* | the field is not set |
A quoted "*" is something different: it is a substring wildcard, and it never means presence.
The two look similar and do not overlap, so it is worth being deliberate about which you want:
numa.mode:*os.cmdline : "*console*"The first asks whether numa.mode is set. The second matches rows whose os.cmdline contains
console, and does not match rows where the field is unset.
Logical
Section titled “Logical”Combine terms with AND, OR, and NOT (all case-insensitive, so AND, and, and aNd
are equivalent). A leading - is an alternative to NOT. Group with parentheses.
| Operator | Meaning | Example |
|---|---|---|
AND | both conditions must match | a = "1" AND b = "2" |
OR | either condition must match | a = "1" OR b = "2" |
NOT | negates the following condition | NOT status = "QIBDO_VM_STATUS_DELETED" |
- | negation prefix (alternative to NOT) | -status = "QIBDO_VM_STATUS_DELETED" |
(status = "QIBDO_VM_STATUS_RUNNING" OR status = "QIBDO_VM_STATUS_STARTING") AND hypervisor = "KVM"NOT name.contains("test")-status = "QIBDO_VM_STATUS_DELETED"Precedence runs NOT/- (highest), then OR, then AND (lowest), and binary operators are
left-associative. Without parentheses, a = "1" AND b = "2" OR c = "3" is read as
a AND (b OR c).
This is the order AIP-160’s grammar defines, and it is the reverse of most programming languages,
where AND binds tighter. The practical consequence is that an unparenthesised mix of the two
almost never means what it looks like:
# Running or starting machines, on KVM — reads naturally, and is wrongstatus = "QIBDO_VM_STATUS_RUNNING" OR status = "QIBDO_VM_STATUS_STARTING" AND hypervisor = "KVM"
# What it actually meansstatus = "QIBDO_VM_STATUS_RUNNING" OR (status = "QIBDO_VM_STATUS_STARTING" AND hypervisor = "KVM")
# What was meant — say it with parentheses(status = "QIBDO_VM_STATUS_RUNNING" OR status = "QIBDO_VM_STATUS_STARTING") AND hypervisor = "KVM"Both readings are valid syntax, so nothing can warn you. Parenthesise every expression that mixes
AND and OR, and the question of which binds tighter stops mattering.
Values
Section titled “Values”- Strings are single- or double-quoted; escape a single quote by doubling it:
name = 'O''Brien'. - Numbers are unquoted, and may be negative or decimal:
cpu.v_cpus_boot > 8,ratio = 3.14. - Booleans are
true/false. - Enums are written as the token the API presents, for example
status = "QIBDO_VM_STATUS_RUNNING". Matching is exact and case-sensitive. The accepted vocabulary for a field is available from the discovery endpoint, and a rejected value returns an error listing what was valid. - Bare identifiers may be written unquoted:
status = QIBDO_VM_STATUS_RUNNING. A bare identifier is resolved against the field’s type: boolean fields readtrue/falseas booleans, numeric fields read digits as numbers, and string fields treat the identifier as text. - Timestamps are quoted RFC-3339 strings:
create_time >= "2026-01-01T00:00:00Z".
Wildcards
Section titled “Wildcards”A * inside a quoted string value is a wildcard (it matches any sequence of characters), usable
with = and != on string fields. For the unquoted star, see
Presence and absence.
| Pattern | Matches |
|---|---|
"alpha*" | values starting with alpha |
"*beta" | values ending with beta |
"*mid*" | values containing mid |
"exact" | exact match (no wildcard) |
name = "prod-*"name = "*-v2"Wildcards are valid on string fields only; using one on a numeric or boolean field returns an
error. A wildcard pattern is a search, so it ignores capitalisation — including when it rides on
=, which is otherwise exact. On a field holding a
fixed set of values a wildcard is refused, for the same reason : is.
Functions
Section titled “Functions”Three string functions are available (string fields only, exactly one argument, camelCase names):
| Function | Matches | Example |
|---|---|---|
contains() | values containing the argument | name.contains("prod") |
startsWith() | values beginning with it | name.startsWith("api-") |
endsWith() | values ending with it | name.endsWith("-v2") |
name.contains("prod")name.startsWith("api-") AND status = "QIBDO_VM_STATUS_RUNNING"NOT name.contains("test")All three are searches, so all three ignore capitalisation, and all three are refused on a field holding a fixed set of values.
Measures and units
Section titled “Measures and units”A physical measure is a single number named for its base unit, so you filter one value and never a magnitude plus a separate unit:
| Field | Base unit |
|---|---|
memory.size_bytes | bytes |
clock_speed_hz | hertz |
bandwidth_bps | bits/sec |
memory.size_bytes >= 8589934592That expression means “at least 8 GiB”. Because the stored value is always in the base unit, the result is the same regardless of the unit a caller originally used, and there is no unit predicate to pair with the comparison. A 64-bit measure is carried as a JSON string in responses, which is the standard encoding for a 64-bit integer; it is still a number and still compares numerically.
Money is the exception. An amount keeps its currency, because a currency is a dimension and not a scale factor, and amounts in different currencies cannot be meaningfully ordered against each other. So ordering by a money field is rejected unless the filter constrains the results to a single currency. Pair the two, as a storage bucket’s month-to-date cost does:
cost.month_to_date.currency_code = "EUR" AND cost.month_to_date >= 100With that filter in place, order_by=cost.month_to_date is accepted. Without it, the request is
rejected. A field that behaves this way is flagged by sort_depends_on_filter in
the discovery response, and
Sorting a field that depends on a filter covers which
expressions actually constrain it — several plausible ones do not.
Nested fields
Section titled “Nested fields”Traversal
Section titled “Traversal”Filter on nested fields with dot notation, up to three segments deep:
cpu.v_cpus_boot >= 8cpu.topology.sockets = 2memory.size_bytes >= 8589934592 AND numa.mode = "STRICT"Which nested fields a resource considers is part of its curated set, exactly like its top-level
fields. Filtering on a field outside the set returns an INVALID_ARGUMENT error that lists the
fields you can use.
Typed comparison
Section titled “Typed comparison”Nested values compare by their real type, not as text. A numeric nested field compares
numerically, so cpu.v_cpus_boot >= 8 matches a machine with 16 vCPUs. Under text ordering it
would not, because "16" sorts before "8".
JSON literal types are preserved end to end, so an integer-valued key matches an integer and a
string-valued key matches a string. A nested field whose type cannot be compared is rejected with
INVALID_ARGUMENT rather than failing at runtime.
Sorting a nested field
Section titled “Sorting a nested field”Most nested fields can be filtered but not sorted. A path that resolves into a stored JSON structure
is filter-only by construction: the engine can test containment cheaply, but it has no ordered index
to sort by, and asking it to returns INVALID_ARGUMENT. A virtual machine shows the usual shape,
advertising far more filterable fields than sortable ones, with every sortable one top-level.
It is not a universal rule. A dotted path that resolves to a real stored column can be sorted, as
cost.month_to_date on a storage bucket is. So do not infer sortability from the shape of the name:
each resource publishes its sortable set beside its filterable set in the reference, and sortable
in the discovery response gives the same answer at runtime. See Sorting.
Discovering the surface
Section titled “Discovering the surface”Every service exposes its query surface at runtime, so a client can build a query interface without hardcoding field names. The endpoint needs no authentication: it returns resource shape metadata, never data.
# Every resource in a servicecurl --get https://api.qibdo.example.com/compute/v1/filterSchemas
# One resourcecurl --get https://api.qibdo.example.com/compute/v1/filterSchemas/virtualMachineEach field in the response carries:
| Property | What it tells you |
|---|---|
name | the field path to use in a filter |
value_type | how values are compared |
repeated_field | whether it is a list, so whether it takes : rather than = |
sortable | whether it may appear in order_by |
operators | the operators this field accepts |
valid_values | the accepted vocabulary for an enum-backed field, empty when any value of the type is allowed |
sort_depends_on_filter | that ordering requires a filter first, as money does |
The response also carries correlatable_fields, one entry per repeated field that accepts
any:
| Property | What it tells you |
|---|---|
name | the field to call any on |
member_names | the fields you may write inside the call, without the leading path |
So a storage class advertising availability with members region_id and status tells you that
availability.any((region_id = … AND status = …)) is accepted. Those members also appear in fields
under their full names, because they remain individually filterable — a correlatable field adds the
correlated question, it does not remove the ordinary ones.
The discovery response and the enforcement engine are derived from the same source, so a field advertised here is always accepted and a field absent here is always rejected.
Examples
Section titled “Examples”# Exact match on an enumstatus = "QIBDO_VM_STATUS_RUNNING"
# Comparison with a timestampcreate_time >= "2026-06-01T00:00:00Z"
# Substring on a scalar stringname : "web"
# Containment on a repeated fieldcpu.features.name : "avx2"
# Presence, then absencenuma.mode:*-numa.mode:*
# Numeric comparison on a nested fieldcpu.v_cpus_boot >= 8
# A measure, in its base unitmemory.size_bytes >= 8589934592
# Combine conditionsstatus = "QIBDO_VM_STATUS_RUNNING" AND hypervisor = "KVM"
# Group with OR, then AND(status = "QIBDO_VM_STATUS_RUNNING" OR status = "QIBDO_VM_STATUS_STARTING") AND -name.contains("temp")
# Prefix wildcardname = "api-*"Limits
Section titled “Limits”To keep queries cheap and safe, filters are bounded:
| Limit | Value |
|---|---|
| Filter string length | 2048 characters |
| Field traversal depth | 3 segments |
| Expression nesting (parentheses and negations) | 4 levels |
| Expression count (comparisons and functions) | 100 |
Two notes on the depths:
- Field traversal depth counts dot-separated segments in a single field path:
a.b.cis the deepest accepted,a.b.c.dis rejected. - Expression nesting counts parentheses and negations together. Each
(and eachNOTor-adds one level, so both((((( ... )))))and a chain of fiveNOTs exceed the limit.
Exceeding a limit, using an unsupported operator or function, or supplying a malformed value
returns an INVALID_ARGUMENT error describing the problem (see
Standard responses).
Errors
Section titled “Errors”Every filter error maps to INVALID_ARGUMENT, and the message names what was wrong. A rejected
field lists the fields you could have used, and distinguishes a field that does not exist from one
that exists but is not filterable. A rejected enum value lists the accepted vocabulary.
| You wrote | Why it fails |
|---|---|
name = "unterminated | invalid syntax (unbalanced quote, (), etc.) |
nonexistent_field = "x" | unknown field for this resource |
create_time = "not-a-timestamp" | value does not match the field’s type |
status = "RUNNING" | value outside the field’s vocabulary |
name.fooBar("x") | unsupported function |
name.contains("a", "b") | a function takes exactly one argument |
cpu.v_cpus_boot : "8" | : on a non-string, non-repeated field |
cpu.v_cpus_boot = "*" | wildcard on a non-string field |
name = * | unquoted star anywhere except after : |
a.b.c.d = "x" | traversal depth exceeded (max 3) |
(((((a = "1"))))) | nesting depth exceeded (max 4) |
Not supported
Section titled “Not supported”A few AIP-160 features are intentionally left out. Use the listed alternative:
| Feature | Use instead |
|---|---|
IN operator (field IN ("a", "b")) | field = "a" OR field = "b" |
Struct / composite literals ({...}) | dot-notation field traversal |
Cross-resource traversal (author.email) | filter the field on its own resource |
| Duration / timestamp literal types | quote them as strings ("2026-01-01T00:00:00Z") |
| Regex or glob operators | the * wildcard or contains/startsWith/endsWith |
any inside any | one level only; no list today carries a nested list |
Sorting
Section titled “Sorting”Filtering selects which rows return; order_by controls their order. Pass a
comma-separated list of fields, each optionally suffixed with a direction ( asc or desc,
case-insensitive). The direction defaults to asc, and results fall back to a stable order when
you omit order_by:
order_by=create_time desc, nameThe filterable and sortable sets are published separately per resource, because they are genuinely different sets. A field may be one, the other, or both:
- Most nested fields are filterable but not sortable (see Sorting a nested field).
- Some fields are sortable but not filterable. A registry artifact can be ordered by
push_time,pull_timeandsize_bytes, none of which it accepts in a filter. - A few require a filter before they can be ordered, flagged by
sort_depends_on_filter. Money is the standard case.
Read the resource’s own two lists rather than generalising from a field’s name. They are in the
reference beside each List operation, and in sortable on the discovery response.
Sorting a field that depends on a filter
Section titled “Sorting a field that depends on a filter”A field flagged sort_depends_on_filter is conditionally sortable: it carries a unit that
varies from row to row, so sorting it depends on the filter first restricting that unit to one
value. Money is the standard case — a currency is a dimension rather than a scale factor, so 100
JPY and 90 USD have no order between them, and nothing is converted on your behalf.
To sort such a field, satisfy the dependency by fixing its unit:
cost.month_to_date.currency_code = "EUR" AND cost.month_to_date >= 100order_by=cost.month_to_date descWithout that filter the request is rejected, and the error names the field it depends on:
Cannot sort by 'cost.month_to_date': it is conditionally sortable and depends on'cost.month_to_date.currency_code'. Its values carry a unit that varies between rows, so amountsin different units cannot be ordered against each other, and are never converted. Addcost.month_to_date.currency_code = "<value>" to the filter, joined with AND, so the request coversone unit. Only a plain equality satisfies this: a wildcard, '!=', or an equality inside an ORstill allows several.What satisfies the dependency
Section titled “What satisfies the dependency”Only a plain equality reached through AND narrows the request to one unit. This is the part
that most often surprises people, because several expressions look like they should satisfy the
dependency and do not:
| Expression | Satisfies it? | Why |
|---|---|---|
currency_code = "EUR" | yes | exactly one value |
currency_code = "EUR" AND size_bytes > 0 | yes | the equality is on the AND spine |
currency_code != "EUR" | no | rules out one value, leaves the rest |
currency_code = "E*" | no | a wildcard; E* spans EUR and EGP |
currency_code:* | no | asks whether a currency is present, not which |
currency_code = "EUR" OR currency_code = "USD" | no | either remains possible |
The dependency is satisfied by the whole expression, not by a clause in isolation: an equality
sitting inside an OR branch does not satisfy it, because the other branch is still reachable.
The dependency is discoverable rather than something to memorise: sort_depends_on_filter on the
discovery response names the exact field whose value must be fixed.