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 = or_expression ;
or_expression = and_expression { OR and_expression } ;
and_expression = unary_expression { AND unary_expression } ;
unary_expression = ( NOT | "-" ) unary_expression | primary ;
primary = "(" expression ")" | function_call | presence | comparison ;
presence = field ":" "*" ; (* unquoted star, only here *)
comparison = field comparator value ;
function_call = field "." IDENTIFIER "(" value ")" ; (* exactly one argument *)
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" |
Matching is exact and case-sensitive, against the value as the API presents it. A wrong-case
value returns no rows rather than an error, so "kvm" will not match "KVM".
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 | Example |
|---|---|---|
| Scalar string | substring match | os.cmdline : "console" |
| Repeated (a list) | element containment | cpu.features.name : "avx2" |
| Nested structure | containment | os.boot_devices : "NETWORK" |
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 >= 8Presence 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 AND, then OR (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. Use parentheses to override this.
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.
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")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.
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 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 |
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.