filters object of an observer’s source. It decides whether one call of the
observed function reaches one observer’s target. The language is declarative JSON (iii-filter v1):
small, total, without side effects or regular expressions, so the same filter gives the same answer
wherever it runs:
- in the engine, for every call it routes: it holds the call’s input, caller, and metadata, and
takes its output or error from the answer of the worker that ran it. Engine-hosted functions and
functions registered with
invocation(HTTP-invoked) are among them; - in the SDK of the worker that hosts the function, for the calls it makes to its own functions,
which the engine never sees, and in
obs.matches(call).
iii-filter crate), and the Node, browser, Python, and Go evaluators all
run the same conformance vectors
(sdk/fixtures/filters). For how
observers use filters, see Observers.
args are for the function’s worker and filters are for iii. A function’s args (a schedule, a
route, a table) go to the worker that hosts it, whose hooks decide what to set up and which
observers each call is for. Filters decide which of those calls reach the target, and the worker
never changes them.The document
A filter reads one document per call, with five roots:- A filter may reference any root, whatever the observer captures in its payload. A filter on
metadata.*works withoutmetadataincapture, and the default capture (["input", "output", "caller"]) leavesmetadataout of the payload. capturedecides the outcomes first:inputandoutputfire on success,erroron error. It is checked before the filter. (An observer that uses the deprecatedongets its outcomes fromoninstead; see Legacyonanddeliver.)- To observe failed calls only and keep their input, capture
["input", "error"]and filter on{ "error": { "$exists": true } }: the successful calls are dropped where the filter runs, in the hosting worker’s SDK or in the engine, and never reach the observing worker (see Failed calls with their input). outputis the handler’s return value, which is what observers observe. An event source’s handler returns the event it is called with, so itsinputandoutputare the same value.
Syntax
{ "input.op": "insert" } is INVALID_FILTER; write { "input.op": { "$eq": "insert" } }.
- A filter is a JSON object. Anything else,
nullincluded, isINVALID_FILTER. An absentfiltersmatches every call. - A filter object holds paths,
$and,$or, and$not. Any other$key isINVALID_FILTER, value operators included ({ "$eq": 1 }needs a path). $andand$ortake a non-empty array of filters ([]isINVALID_FILTER).$nottakes one filter.{}is allowed there ({ "$not": {} }matches nothing) and as a member of$andor$or(it matches everything).- An operator object is non-empty and holds only value operators. An empty
{}, a key without$, an unknown operator, or$and/$or/$notinside it isINVALID_FILTER.
Paths
- A path splits on unescaped dots.
\.is a literal dot and\\a literal backslash; any other backslash sequence and a trailing backslash areINVALID_FILTER. In a JSON string the backslash itself is escaped:"input.headers.x\\.tenant". - An empty path or an empty segment (
input.,.input,input..a) isINVALID_FILTER. - The first segment is the root:
input,output,error,caller, ormetadata(case-sensitive). A path may be the root alone:"error": { "$exists": true }. - An index segment (
0,1,12) selects an array element.01and-1are keys, never indexes. On an object, a numeric segment is a key. @is a root only inside$any, where every path starts at it. After the root,@is an ordinary key.
Operators
The operators of one operator object must all match:
{ "input.total": { "$gte": 100, "$lt": 500 } } is a range.
$any
There is no implicit fan-out over arrays: input.items.sku on an array of items is absent. Use
$any:
- Scalar elements: pass an operator object.
{ "input.tags": { "$any": { "$eq": "vip" } } } - Object elements: pass a filter whose paths start at
@, the current element.{ "input.items": { "$any": { "@.sku": { "$eq": "A-1" }, "@.qty": { "$gte": 10 } } } }
{ "$any": {} } matches any non-empty array. A nested $any binds @ to its own element. An empty
array, a value that is not an array, and an absent path do not match. Mixing operators and paths in
one $any object is INVALID_FILTER.
Matching rules
- Absent paths. A path yields a value or is absent: stepping into a scalar, a missing key, or an
out-of-range index yields absent. Only
$ne,$nin, and$exists: falsematch an absent path; every other operator is false.$notnegates its whole filter, so it matches when the inner path is absent. nullis a value.{ "$exists": true }matchesnull, and{ "$eq": null }matches onlynull, never an absent path.- Own keys only.
constructor,toString, and__proto__are ordinary keys that resolve only when the document holds them. - Equality is JSON deep equality. Object key order is ignored; array order is not. There is no
coercion between types:
0,false,null,"", and"0"all differ. Strings are equal when their code points are (no Unicode normalization). - Numbers compare as IEEE-754 doubles:
1equals1.0, and-0equals0. A payload integer above 2^53 is rounded to the nearest double first. Numeric literals in a filter must be within ±2^53 (9007199254740992), floats included (1e16isINVALID_FILTER). - Ordering applies within one type only. Numbers compare numerically; strings compare by Unicode
code point (UTF-8 byte order, not JavaScript’s UTF-16 order). Any other pairing (mixed types,
null, booleans, arrays, objects, absent) is false for all four operators. $in: []matches nothing;$nin: []matches everything, absent included. An array value is compared with each item as a whole.$prefix: ""matches every string.- Host values. SDKs evaluate as if on the JSON encoding of the document:
undefinedis absent, aDateis its ISO string, and aBigIntorDecimalcompares through its JSON number. - Totality. A filter that was valid at creation answers true or false for every document. It never fails.
Many observers
The engine matches a call against all of a function’s observers through an index of their filters’ equality conditions. It files each observer under one$eq (or $in) condition of its top-level
$and whose operand is a scalar (a number, a string, a boolean, or null), by path and value, and
keeps the observers without one in a list. A call costs one lookup per indexed path, plus a full
evaluation of the filters it finds there and of those in the list: with 4,000 observers that each
filter on their own input.tenant, a call evaluates one filter. The index uses the equality rules
above (1 and 1.0 are one value), so its matches are exactly the observers whose filters match.
A filter costs a call nothing when the call does not meet the condition the index files it under.
The index files each filter under one condition only, preferring a condition of one value to an
$in of several, then the value the fewest other observers are filed under, so a filter with
several equality conditions is evaluated on every call that meets the one it is filed under,
whatever its other conditions say. A filter without one (its conditions are ranges, $ne, $nin,
$exists, $prefix, conditions inside $or, $not, or $any, or an $eq of an array or an
object) is evaluated on every call.
Where the lookups would spare a call little, the engine evaluates every observer’s filter, as a scan
does: when the index files fewer than two observers per indexed path, and when a call meets the
conditions of half the observers or more. A call therefore costs at most about the evaluation of
every filter plus one lookup per indexed path. A function none of whose observers has an indexable
filter keeps no index, and its calls evaluate every filter.
Validation
The engine validates every filter when an observer is created and answersINVALID_FILTER or
INVALID_FILTER_PATH in the observer’s error state.
Limits
Every limit fails with
INVALID_FILTER. Documents have no limits.
Schema checks
When the observed function registered arequest_format (for input.* paths) or a
response_format (for output.* paths), the engine checks each path against the schema’s
properties and items:
- A property that a closed schema (
additionalProperties: false) does not declare isINVALID_FILTER_PATH, so a typo such asinput.tabelfails at creation instead of matching nothing. - Under an open schema, the same path is accepted with a warning in the observer’s result. So is a
construct the check cannot follow: a remote
$ref,anyOf/oneOf/allOfwith several branches,patternProperties, orif. Local$refs and nullable wrappers are followed. - A path that reaches a
falseschema isINVALID_FILTER_PATH. @paths inside$anyare checked against the array’sitems.- When the function is not registered yet, the checks run when it registers.
args are validated separately, against the observed
function’s args_format (INVALID_ARGS), and filters cannot reference them.
Normal form
The engine normalizes every filter before it stores it or pushes it to the worker that hosts the function (engine::observers::list shows the normal form too):
- Every filter, nested ones included, becomes
{}or{ "$and": [ ... ] }with one clause per object. - Explicit
$ands are flattened into the filter that holds them. - Clauses are sorted by key in byte order (
$notand$orbefore paths), then by the compact JSON of their operand, and exact duplicates are dropped. - Operator object keys are sorted. Operands,
$in/$ninitems, paths, and the order of$ormembers are kept as written.$neand$ninare not rewritten.
Examples
From iii lang predicates
A filter maps one to one to a predicate of the iii language, which is why the language accepts explicit operators only:$exists, $prefix, and $any map to functions of the predicate language.
Evaluate a filter in code
A worker evaluates an observer’s filters, with itscapture, through obs.matches(call) (Matches in
Go). An onObservationDeliver hook that runs a target itself with iii.call({ observer, payload })
checks the call this way first, for example to answer a request differently when the filters reject
it. The Python, Rust, and Go SDKs also expose the evaluator itself, which is handy in tests:
- Python
- Rust
- Go