9. Caps, cost, and diagnostics
Because a Virtual Cypher query reaches into live external systems, it is bounded on every axis, and a bound that bites is always surfaced, never silent.
What a finished query reports it cost — apiCalls, llmCalls and semanticCalls, and which calls
land on which — is §6.7.
What a refusal carries. A refusal is read as often by a model about to rewrite the statement as
by a person reading a message, and a caller that regenerates has nothing to work from but the
refusal itself. So three things hold of every refusal this document names, whatever its kind
(BUDGET_EXCEEDED, TOO_EXPENSIVE, INVALID_QUERY, SOURCE_UNAVAILABLE), and each can be
observed by issuing a query and reading what comes back:
- A refusal names its measure, its size and its limit, and it carries a lever. The measure is
what was counted — distinct keys, records, rows, match steps, model calls, pairs, rounds,
characters — and it is named, because one statement can pass several different bounds and each has
a different fix:
maxAnchorscounts distinct keys and not records, and its refusal says so. The size is what the statement would have spent against that measure; the limit is the bound it passed. The lever is what to change, given as a spelling, a clause or a declaration rather than as a category of them: a predicate to push, aLIMITto add, aMATCHto narrow, an aggregate to move earlier, a second declared door to pin with{via:'…'}, a cap to raise deliberately. A lever applies to the statement it was given for — taking it and re-issuing is how a refusal is answered, so a lever that leaves the same statement refused the same way is a defect in the refusal rather than a matter of wording. It is also a lever the recipient can take: a source that applies no filters declarespushdown: [], and a door read whole declaresreadWhole: true, and each is then advised nothing, because advice to write a rule the source cannot apply, or one the door already declares, is advice no author can act on. - The same statement is refused the same way. A refused statement, re-issued unchanged against unchanged declarations, is refused again with the same kind, the same measure and limit, and the same text. A refusal is a property of the statement and of what the realms declare, not of the attempt, so two identical attempts never disagree about what was wrong, and a caller that rewrites against a refusal is never sent after a bound it has already cleared. Where the outcome genuinely depends on what a source or a deployment did — a throttled call, a source that is down, a time bound one host sets and another does not — the result says which of those it was, and says it as something other than a fault in the statement.
- A bound is labelled as a bound. Where a figure a refusal or a warning carries is a floor and not a total, it says so in its own words — “at least 50001 row(s)”, “of the records the source answered” — and never as a plain number. A count stopped one row past its budget (§13.7), an aggregate over a truncated read, a membership derived from incomplete facts: each is a floor, and a floor read as a total is a caller re-planning against a price nobody quoted it. The converse is the same guarantee: a figure given plainly is exact.
Caps (per join):
-
maxAnchors(default 200) — refuse, before any call is made, a join that would ask its source for more distinct keys than this (a fan-out guard; pin/filter the anchor).The cap is spent in distinct keys, not records — and not anchor rows either. A per-key source is asked once per distinct key however many anchors share it, and the cap exists to bound those calls, so a thousand stored anchors in three categories cost three against it and every anchor keeps its records, while a thousand anchors with a thousand keys are refused, by the keys counted. The refusal says what it counted: the join would ask the source for N distinct keys, exceeding
maxAnchors. That distinction decides what helps. Pushing a predicate on the TARGET makes each call cheaper and leaves the NUMBER of calls exactly as it was; only narrowing the anchor set — or reaching the target through a different door — changes whether the query is refused.Declare a second door where your source offers one. A join is one call per anchor, so a realm that also declares the same target keyed on something else gives a query a way in that does not fan out at all:
virtualJoins:# One account's cases — one call per account.- { anchorLabel: CustomerAccount, relationship: HAS_CASE, keyField: accountKey,recordKeyField: accountKeyAsked, producer: casesByAccount }# Every open case — one call for the whole desk.- { anchorLabel: ChatwootDesk, relationship: HAS_CASE, keyField: status,recordKeyField: deskStatus, producer: casesByStatus }A query pinning
c.statushas already bound the second door’s key, and the accounts fall out of the rows it returns. Pin it explicitly with{via:'…'}, and where the host enables access-path rewriting the engine may choose it for you — but only where it can show the answer is identical. It will decline, and say so, when the hop isOPTIONAL(anchors with no rows would be lost, and “absent from this list means none” is a real reading), when an aggregate folds the anchor, when another predicate on the target would go unapplied, or when the door’s key is not pinned.So the second door is worth declaring whether or not the rewriter is on: it is what the author pins today, and the search space the engine gets tomorrow.
-
maxFanoutTotal(default 5 000) — the most nodes one materialization of this join may create, counting each fetched record and every child itsbrings:would make a node of (primary + brought). A fetch that would create more is truncated at the cap, never refused and never silent: the records are materialized in anchor-key order until the next would pass the cap, the rest are held out of the graph, and the result carriesPARTIAL_RESULT(TRUNCATED) naming the cap, how many records the source answered, how many were kept, and the join. A count over the target is then a lower bound, and an aggregate over it is gated as any truncated read is (§9, diagnostics). A fetch exactly at the cap is complete and carries no flag. An anchor the query pinned by literal whose records were held out gets no row rather than a row of zero, since its answer was not “none”.The cap counts nodes, not calls, which decides what it can and cannot do. How many records an anchor answers is unknown until the source answers, so the engine cannot refuse this cap before spending the way it refuses
maxAnchors; the calls are bounded bymaxAnchors, and this bounds what they are allowed to put in the graph. Narrowing the anchor set is what lowers both. A variable-length traversal (§5.12) spends the same cap cumulatively across its hops, and a single hop that is itself too wide is truncated here as well.
Predicate pushdown, and what it does not change: a producer may declare rules mapping query
predicates on its target to the source’s own query language, so a WHERE scopes the fetch at the
source instead of after it. Equality, ranges, CONTAINS, and membership in a list-valued
property ('OLDER_ADULT' IN t.ageGroups) are all pushable when a rule exists for them.
The guarantee is that pushdown changes cost and never rows. Anything a source cannot answer is still applied to the materialized graph, so the same query returns the same rows whether it was pushed or not; only the number of records fetched differs. A value that does not fit the rule’s declared shape is left to the graph rather than embedded in a source query it would corrupt.
And the spelling does not change the fetch either. A predicate written over a name a WITH
introduced — a renamed node (WITH t AS u WHERE u.status = 'open') or a projected property
(WITH t, t.status AS st WHERE st = 'open') — is the predicate on the property it names, and is
pushed exactly as WHERE t.status = 'open' is. Splitting a query into a pipeline for legibility
therefore costs what the one-line spelling costs.
A projection that computes holds a different value from the property, so it is not pushed:
toLower(t.status) AS st, an arithmetic, an aggregate, a coalesce. Pushing such a comparison as
the property’s would drop rows the query keeps, which is a wrong answer and not a cheaper fetch. It
is applied to the materialized graph instead, and SAID so — on EXPLAIN’s residual line and in the
cost note of a read that filtered after fetching — because a filter applied after a wide fetch that
no surface reports is the one cost a realm author cannot find.
A producer whose source applies no filters declares pushdown: []. The empty list is a
statement and an absent key is not: without the key the engine treats every residual as a rule not
yet written — a load warning, a cost note with advice on each read of 25 records or more, and an
instruction on EXPLAIN’s residual line. With it, the engine accepts that every predicate on the
target is applied in the graph by construction, and says so without advising anything. The cost
note is still recorded on such a read, since it is the evidence that the read was complete and the
graph did the filtering, which is what makes an empty result an exact one. The declaration cannot
conflict with rules: it is the pushdown: key itself, holding either rules or nothing.
A door that should be read whole declares readWhole: true. A field on the producer, beside
pushdown: — not inside cache:, because it changes what the source is asked — on every kind that
declares pushdown: rules. Default false. Every read of the door asks the source for the key’s whole
collection; the door’s rules stay declared and none is sent; the graph applies every filter. What a
caller sees:
- every read of the door is keyed the same, so with a
ttlorimmutablecache one read serves every view of it in any order — a family of windowed views costs one read, not one per window; - the request carries no filter from the rules, whatever the query’s
WHERE; - the quote and
EXPLAINshow the filters on the line for what the graph applies, none on the line for what the source applies, and name the declaration instead of advising a rule; - the read keeps its cost note, which says the door is declared
readWhole: trueand advises nothing; as withpushdown: [], the note is the evidence that the read was complete, so an empty answer is exact; - a filtered count over the door is counted in the graph from the whole read, because the source is not asked to filter what it would count.
Loading reports two things about it: readWhole: true without a ttl or immutable cache is a
warning — nothing keeps the read for the next view, so the door only fetches more — and with
requiresFilter: true it is refused, since the door would refuse every read. A read-whole door is not
warned for declaring no rules or for leaving source filters unused. The config catalog’s
producer rows and realm_status’s surface name the doors that declare it.
Two ways to write a rule. A rule renders either text or a structured clause, and it declares exactly one of them.
pushdown: # TEXT: a search qualifier or a `q=` conjunct, with a {value} slot. - property: html_url op: CONTAINS qualifier: "repo:{value}"
# STRUCTURED: a clause placed in the producer's own args, for a source whose # query is JSON rather than a string — an Odoo domain, an Elasticsearch # bool.filter, a GraphQL where:. - property: status op: IN argPath: domain.- clause: ["status", "in", "{values}"]argPath addresses the producer’s args exactly as keyArg does, with one addition: a trailing
- means append to the list there. That is what lets a conjunction take another clause without
the rule knowing how many are already present — and without moving an index an existing keyArg
addresses, which inserting at the front would silently do.
Inside clause, {value} is substituted into a string, and a node that is exactly "{values}"
is replaced by the predicate’s member list. The distinction matters: a set must reach the source as
real JSON (["status", "in", ["open", "pending"]]), not as text that happens to look like it.
linkPrevious is for a source that spells conjunction as a field on the PRECEDING element
rather than implicitly. Chatwoot’s conversation filter is one: each clause carries query_operator
naming how it joins to the next, and the last must not carry one at all — so it cannot be declared
statically on the base payload, only written when a clause is appended.
pushdown: - property: status op: IN argPath: payload.- clause: { attribute_key: status, filter_operator: equal_to, values: "{values}" } linkPrevious: { query_operator: AND }Omit it for a source whose list is an implicit conjunction — an Odoo domain, an Elasticsearch
bool.filter — where appending is the whole operation.
Verify a pushdown rule against the live source. These shapes are unforgiving and fail loudly
rather than subtly: appending to a Chatwoot payload without query_operator is an HTTP 500, and
including it on the final clause is rejected outright. A rule that renders nothing is correctly
reported as absorbing nothing, so a wrong rule leaves the query slow rather than incorrect — but
slow is what the rule was for.
Enumerated sets (WHERE c.status IN ['open','pending']) push by member expansion. A text
rule renders them as an OR group, and only into a conjunction-style qualifier that has one — a
space-separated search qualifier does not, since status:open status:pending means AND to a search
API and matches nothing. Where a rule cannot express the set, it renders nothing and the predicate
stays a graph-side filter.
An OR of equalities on ONE property of ONE fetched variable (c.status = 'open' OR c.status = 'closed') is the same set spelled differently, and is pushed exactly as the IN it spells: the same
rule, the same OR group in the qualifier, the same residual, with a bound parameter among the arms
read as its value. An OR that mixes properties, variables or non-equalities — c.status = 'open' OR c.priority = 'high', c.status = 'open' OR NOT c.closed, an arm on another node — pushes
nothing and stays with the graph, since no fetch narrowed on one property’s values returns every
row such a disjunction admits. The two spellings are also declined together: a rule declaring only
EQUALS absorbs neither, because the engine never splits a set into one call per value.
What “absorbed” means, and why it is load-bearing. A rule that renders nothing is reported as
having absorbed nothing, and the engine plans accordingly. This is not bookkeeping: the residual —
what the source did not take — is what decides whether a LIMIT may be pushed and whether a count
may be asked of the source instead of fetched. A rule that claimed a filter it did not send would
license the engine to read one page and call it the whole answer.
A pushed LIMIT is a count of records that suffice, and a SKIP adds to it. When every
predicate on the target is absorbed, a terminal LIMIT n bounds the fetch at n records and the
page walk stops at the page that holds them. SKIP s LIMIT n bounds it at s + n: the skipped rows
must be read to be skipped, and the number that suffices is written in the query, so the bound
widens rather than lapsing. ORDER BY r.v SKIP 1000 LIMIT 10 over an offset-paged source that
declares and honours orderedBy: v asc reads the pages holding the first 1,010 records, not every
page. The store still applies SKIP and LIMIT to the rows. What the fetch can count is a SKIP
written as a literal in the top-level RETURN, or a parameter the caller supplied (substituted before
the query is read). A SKIP it cannot count — an expression (SKIP 999 + 1), a SKIP in a WITH,
a subquery or one arm of a UNION, a RETURN SKIP beside a LIMIT written in an earlier WITH,
or a sum too large to hold — never under-counts: it releases the bound, and the fetch reads the
source whole and still answers right.
An existence test is capped at one record per key, and asked per key only where a page walk
could starve a key. EXISTS { (a)-[:R]->(:T) } needs one record per anchor key to answer, so the
fetch behind it is capped at one. Where the source pages, the keys are asked one per call: a
batched page walk would stop at page one and declare the keys not on that page empty. Where the
source does not page — one call returns everything it holds for the keys asked — a batch under the
cap is complete per key, so the keys are asked in batches of maxKeysPerCall: three hundred
anchors on such a source are six calls of fifty, each anchor in exactly one, not three hundred. A
source declaring batchSafe: false is asked one key per call whether it pages or not, since that
declaration says a batched answer is not complete per key.
Asking the source for a number instead of the records: a producer may declare what it can aggregate, so a query that only counts costs one call rather than one call per anchor.
aggregates: - measure: count # count | sum | min | max operation: cases.counts # the source's own aggregate endpoint; omit to reuse `operation` valueField: total # where the number is in the response keyField: accountKey # which key the number belongs toEvery field is required for a reason:
valueField— a response shaped{"count": 12}and one shaped{"__count": 12}are equally plausible, and reading the wrong one yields a plausible integer rather than an error.keyField— a grouped response with no key echo cannot be attributed, and attributing it by position is how one account’s case count silently becomes another’s.of:is required forsum/min/max—sumwith no property is not a question a source can answer.
avg is deliberately not supported: an average over one page cannot be recombined across pages, so
a source reporting only an average cannot be trusted to have averaged everything. Declare sum and
count instead.
Which kinds may declare aggregates:. The capability is source-agnostic; the ABILITY to honour
it is not.
| kind | may declare | why |
|---|---|---|
remote (api) |
yes | the capability declares the source’s own aggregate endpoint and where the number sits in its response |
sql |
yes, on a KEYED table read | the statement is generated, so count compiles to SELECT COUNT(*) … GROUP BY <keyColumn> with the same keys, the same governance where: and the same pushed filters |
cypher, sparql, graphql |
no, not yet | each could answer one — count(o), COUNT(DISTINCT ?x), a Relay totalCount — but its query is AUTHORED, and nothing in the declaration says which second query returns the number. Refused rather than guessed |
generative, compute, extract, aggregate |
no | there are no source rows to count: the records are produced here, so counting them means producing them |
tabular, files, jar, source, feed, vector, keyword, elasticsearch, publisher, … |
no | the unit of cost is the whole download or the whole retrieval, which has to happen before anything can be counted |
A sql producer’s declaration is lighter, because the engine writes the statement: measure — and
of, the column, for sum/min/max — is the whole of it.
- name: linesByOrder kind: sql datasource: warehouse table: order_lines keyColumn: order_id aggregates: - measure: count valueField: n # any name for the number; the statement aliases it - measure: sum of: amount_cents valueField: totalkeyField defaults to the producer’s own keyColumn, since that is what the statement groups by.
valueField still has to be named — it is the alias the number comes back under — and must not be
the key column, which the same row already carries.
Four sql shapes are REFUSED when they declare an aggregate, each for its own reason: a scan: is
already a bounded TOP N (counting its rows answers a question about the bound, not about the table);
a procedure: returns whatever it returns, with no SELECT list to replace; keyMatch: ci answers
one key per statement and a row may satisfy several keys at once, so a GROUP BY over it would
attribute one key’s number to another; and a key column under an identity dialect: comes back in
the source’s own spelling, which links to no anchor. Under mode: governed, a sum/min/max over
a column that expose does not list is refused too — a total over a column the caller cannot read
is an oracle over hidden values — and so is one over a masked column, since a number computed in the
database arrives past the mask.
A declaration on a kind that cannot honour it is an ERROR, not a dropped line. realm_validate
names the kind, says it has no way to answer a number without returning its records, and lists the
kinds that can. That is deliberate: silently dropping it would leave a realm that reads as though
its source counts for itself, and the only symptom would be a query that stays exactly as slow as it
was.
Declared rather than assumed, for every kind including the relational one. “A database can always
GROUP BY” is a fact about databases, not about this table under this governance — and an arbitrary
REST endpoint cannot be presumed to count at all, so a producer that guessed would return a
confident number from whatever the endpoint happened to send back.
The engine pushes an aggregate only when it is safe as well as declared: the target must be
projected nowhere else (RETURN c.subject, count(c) needs the rows whatever else it asks), every
aggregate over it must be pushable (one collect means the records are needed anyway), count(*)
never pushes because it folds rows of the whole pattern, count(c.property) never pushes because it
counts VALUES rather than nodes, and any predicate on the target must be one the source absorbs — a
source can only count what it can also filter. All four measures execute; the engine pushes only
when EVERY measure in the query is deliverable, and two measures over one property refuse because
they would want the same slot.
This makes the SHAPE of a view a cost decision. collect(c.subject) needs the records whatever
else the query asks, so a view that both counts and collects always pays the collecting price —
including when the question was triage across the whole book and nobody was going to read a subject.
Write the two questions as two views:
# Triage across every account — answerable by the source.- name: HealthOpenCaseCountsByAccount cypher: | MATCH (a:CustomerAccount)-[:HAS_CASE]->(c:SupportCase) WHERE c.status IN ['open', 'pending'] RETURN a.accountKey AS accountKey, count(c) AS openCases
# The detail, for the accounts a pane is showing.- name: HealthOpenCasesByAccount cypher: | MATCH (a:CustomerAccount)-[:HAS_CASE]->(c:SupportCase) WHERE c.status IN ['open', 'pending'] RETURN a.accountKey AS accountKey, collect(c.subject) AS subjectsEach description should point at the other: the counts view names what to ask for detail, and the detail view says to ask it about named accounts rather than the whole book.
Cost (per producer): a cost: block declares the source’s shared rate bucket and limit.
The planner budgets producer calls against it and, when a query can’t fit, emits EXPLAIN-style
advice (push a predicate, add a LIMIT, narrow the anchor) rather than silently over-calling.
Bounds (per statement): the caps above are declared per join and bound what a query asks of a
SOURCE. Three further bounds belong to the statement itself and bound what it may spend in the engine.
None is declared in a realm, none can be raised from a query, and any one stopping a
statement is reported as a refusal of kind BUDGET_EXCEEDED carrying the limit it exceeded and the
unit that limit is in:
- a work bound, counted in match steps — the candidate space a pattern walks. A statement that exceeds it returns nothing and is told which of the two causes is its own: a pattern with nothing to anchor it, or too many node sets matched at once, where the candidate space is the product of them even though each set is labelled.
- a records bound, counted in fetched records: how many records the statement’s fetches may
bring into the graph it runs on, across all its stages and
UNIONbranches (200,000 unless the host sets another). A fetched record is in the graph before any aggregation orLIMITreads it, socount(*)over every call site of a large project brings every call site in; a statement past the bound returns nothing and is told to fetch fewer. A filter on the fetched node’s own properties or on parameters — in its property map or in theWHEREthat reaches it (WHERE c.method IN $methods,WHERE d.path STARTS WITH $dir) — is applied before records are brought in, and is the lever; so is reading the source in parts, one statement per part. An aggregation or aLIMITis not. - a time bound, counted in milliseconds, off unless the host sets one. It is a backstop, not a second way to say the same thing: work is counted in match steps, and a statement whose cost lies somewhere those are not charged — a filter evaluated per candidate over a wide match is the shape to know — can run a long time while the work bound barely moves. The work bound is the one a query should ever meet, because it can say what to do about it; a statement that reaches the time bound is told what is usually true of one that does, which is that it is unbounded in a shape the other bound cannot see.
A host that sets no time bound has only the work and records bounds. Where a host does set one, it is a property of the deployment and not
of the query: the same statement may be answered on one appliance and declined on another, and a
realm cannot tell which from its own files. So a realm must not rely on any of these bounds as control
flow. They exist to make a runaway statement fail loudly and diagnosably instead of spinning; a
query whose correctness depends on being stopped is a query to rewrite, by narrowing the match
before filtering it — anchoring it, or staging the joins with WITH and aggregating as you go.
A rate limit is never an empty answer. When a source’s rate bucket cannot fit every lookup a
query needs now, the lookups that fit are made and the rest are deferred. The answer is flagged
partial and says how many were looked up and how many are pending; asking again fills in more.
Zero rows with every lookup deferred mean “not looked up yet”, never “none”. When the deferred work
is more than re-asking will ever complete, the flag says so and asks for a narrower query instead
of promising that the rest fills in. A source that THROTTLES a call (an HTTP 429) is reported as
RATE_LIMITED. With no rows the query is refused as SOURCE_UNAVAILABLE with that code; with some
rows the answer is flagged partial. Either way the result is unanswered, not empty, and the
integration is fine: nothing needs reconnecting. Ask again shortly, or pin a specific entity so the
query needs fewer calls.
Model calls are priced before they are spent. Fetches that call a model (extraction,
generation, a model-backed producer), ai.* judgments (§7) and generative rounds all draw on one
per-query limit on cold model calls. The quote prices what it can measure before the run. Work that
only becomes known as the run goes — a later stage whose anchors are an earlier stage’s results,
per-row judgments over rows not yet fetched, a generative producer’s further rounds — is charged
against the same limit as it begins. A step that would exceed it refuses the whole query with
TOO_EXPENSIVE, naming the count and the limit, before that step’s call is made: work no quote
showed can never be spent unasked. A warm anchor costs nothing. A query that means to pay says so
({ai: {materialize: true}}) or runs as a background job.
A bound that stops the run is BUDGET_EXCEEDED. A statement that exceeds the graph engine’s
work, row or records budget, or a rule set that exceeds maxRounds or maxPairs (§13.7), is refused with
BUDGET_EXCEEDED, naming the limit and its unit, and returns nothing: never a partial set presented
as complete. A statement budget is fixed and cannot be raised from a query. The refusal names the
levers: aggregate earlier, add a LIMIT, narrow the MATCH, filter before sorting.
Some statements are refused by their shape, before anything parses them. These bounds are read from the statement’s text on every query surface, so nothing is fetched or run; a string literal, a comment or a quoted name is data and counts for nothing. Each refusal names what it found and the spelling that answers. A rule body (§13) meets the nesting and regular-expression bounds too.
- Nesting. One expression nests inside another by brackets,
CASEinsideCASE, a prefixNOT, a unary sign, or a property read or subscript chained onto a value (x.a.b,x[0][1]). All of these count together toward one limit of 128 levels; past it the statement is refused asBUDGET_EXCEEDED, naming the constructs that made it deep. - Literal ranges. A
range()whose bounds or step are written as literals is checked before execution. One whose bounds are more than a 64-bit integer apart, so its size cannot be counted exactly, or that makes more values than a list holds, is refused asINVALID_QUERY, as is a FLOAT bound or step.UNWINDs of literal ranges, one directly after another, whose product exceeds 10,000,000 rows are refused asBUDGET_EXCEEDED. ALIMITafter them is not counted as bounding them, since a sort, an aggregation or aDISTINCTbefore it reads every row first. - Regular expressions. A
=~whose pattern backtracks super-linearly on a text it does not match — a repetition of varying length nested in a repetition with nothing to separate one turn from the next ((a+)+,(\w+\s?)*), or a repeated alternation whose branches overlap ((a|aa)*,(\w|\d)+) — or whose pattern is longer than 4,096 characters, is refused asBUDGET_EXCEEDED. A match cannot be stopped once it has started, so it is refused rather than tried.([a-z]+,)*and(cat|car)+are linear and run.
What an expression costs is bounded as it runs, too. On the in-process engine a regular-expression
match is charged to the statement’s work budget for every character it reads, and a STRING is
charged for the characters it builds, so a pattern only the run can see is still bounded. No STRING
may exceed 16,777,216 characters: the in-process engine refuses to build a longer one, and an answer
holding one is refused on either engine, as BUDGET_EXCEEDED, naming the expression that grew it.
Query-shape advice: EXPLAIN also names a shape the store runs correctly but quadratically —
an equality join between two matched sets with a function wrapping one side, WHERE k.id = toString(n.res_id), which a planner cannot hash-join and so filters every pair (measured: 13.6 s
against 2.5 s on 6,000 × 4,010 rows). The advice line quotes the clause and the spelling that
hashes: project the function first (WITH n, toString(n.res_id) AS nResId), then compare the two
plain values (WHERE k.id = nResId). Advice never changes what the query runs or answers; the
advised spelling answers the same rows and draws no advice of its own.
Diagnostics — what a 0-row or partial result means: a fetch that returns nothing is indistinguishable from “genuinely no data” unless the engine says otherwise. Every producer failure is classified and surfaced as a warning on the result:
| diagnostic | when | meaning |
|---|---|---|
PRODUCER_ERROR (FETCH_FAILURE) |
a timeout, a missing gateway tool, a non-auth error | the source could not be reached — not “no data”. Fix the integration. |
PRODUCER_ERROR (RATE_LIMITED) |
the source throttled the call (an HTTP 429) | the source is up and the credentials are fine — the result is unanswered, not empty. Ask again shortly, or pin a specific entity so fewer calls are needed. Never “reconnect”. |
PRODUCER_ERROR (AUTH_EXPIRED) |
a 401 / “token expired” / EXPIRED_AUTHENTICATION |
the OAuth token has expired — reconnect to refresh. The empty result is because the source rejected the call. |
PARTIAL_RESULT (TRUNCATED) |
pagination hit maxPages with a still-full last page; a call budget ran out mid-fan-out; a join’s maxFanoutTotal held part of what a fetch answered out of the graph; a declared floor or gate dropped records |
the fetch succeeded but is incomplete — the source has more. Not a failure. The detail says which cause, because the fixes differ: raise the cap, narrow the anchor set, or simply ask again (a budgeted run keeps its completed work and resumes rather than restarting). |
PARTIAL_RESULT (TRUNCATED) |
a singlePage producer (one whose listing declares paging it cannot walk) returned any records |
the source may have more: the producer cannot tell the API’s default first page from the whole listing, so the warning appears even when nothing is missing. Raising maxPages or asking again cannot change it; declare paging: so the listing can be walked, or read a listing that returns its whole collection. |
PARTIAL_RESULT (NOT_FOUND) |
the source answered a definitive 404 for one key of a fan-out | that key does not exist — as opposed to “we could not find out”, which is FETCH_FAILURE. The answer is short by exactly the named keys; the source is not down, and the other keys’ rows are good. |
PARTIAL_RESULT (DERIVED_LOWER_BOUND) |
a DERIVE rule set concluded while the facts its rules read were themselves incomplete (another diagnostic fired, or a demand went unmet) | membership and any aggregate over it are a lower bound, not a total. The fixpoint is correct over what was materialized and says nothing about what was not — a rule body cannot fetch (§13.6). Not a failure, and not “nothing was derived”. |
PARTIAL_RESULT (UNREACHED_BACK_HOP) |
a hop reached a fetched label from a node that has no declared door onto it, so the label could only match records the query had already fetched some other way | membership, and any count or list over it, is a lower bound; a total, average, minimum or maximum over it describes only the records it read and may be off in either direction. Every row returned is true — what is withdrawn is only the claim of completeness. The warning quotes the hop as the query wrote it and names the doors that would give the node rows of its own. Not a fetch failure, and not “no more data”. |
INCOMPLETE_TRAVERSAL |
a variable-length traversal (§5.12) stopped short of its declared depth | the warning names the hop reached and the bound hit (maxFanoutTotal, frontier width, or the unbounded-* cap). Rows cover only the hops walked; any subtree total is a lower bound. |
UNKNOWN_VIA |
an edge pinned {via:'…'} that no declared join offers |
the rows are real but came from a different join than the one named. The query still answers — a via that does not exist must not cost a good answer — and the warning lists the vias that do exist so it can be re-issued. Matters most where several joins converge on one label, since the substitution is otherwise invisible. |
AMBIGUOUS_LABEL |
the query named a parent label that more than one installed type answers to through the same edge | nothing was fetched, so the empty result is not “no data”. The engine will not choose between a customer’s two helpdesks on the caller’s behalf; the detail names the types, so the query can be re-issued naming one. |
NEEDS_FILTER |
a source that cannot be swept was asked without a narrowing predicate | the answer is unknown until the query is narrowed — not “no data”. |
FILTER_STARVED |
candidates were found, and the query’s own filters rejected every one | zero matches among fetched candidates, not proof of absence elsewhere. If UNPUSHED_FILTER also appears, the requested record may be outside the fetched page. |
UNPUSHED_FILTER |
a bounded source read (page cap or limit reached) omitted a declared filter (queryArgs or sourceFilters) and could not establish a match for that target alias, including when it returned zero records |
the bounded fetch cannot prove absence. Supply a valid declared request input or narrow the source request; do not turn this warning into a clean zero. A failed read carries its failure diagnostic; a skipped read does not establish absence. |
FIELDS_WITHHELD |
governance removed fields — the secret reflex, mask: drop, or governed exposure (§5.15) |
the rows are complete; named fields were removed by policy. An absent field here means “not exposed by this source’s governance”, never “no data”. |
COMPUTE_FAILED |
a producer’s compute: expression raised on some records |
the rows are complete and the source is fine; one property is absent on the records named. Absent rather than null or zero, because either would read as an answer. |
MATERIALIZATION_FAILED |
the source returned data the graph could not store | the records exist but are missing from the result, so an answer of “none” is definitely wrong. The fix is the engine’s, not the caller’s. |
REDUCTION_FAILED |
an aggregate reduction or an extract call failed |
the anchor’s reduced or extracted records are absent, not empty. Retried on the next traversal. |
EMPTY_NOT_KEPT |
keys of a ttl-cached fetch answered empty, and the policy declares no negativeTtlSeconds |
a cost note over a correct answer, not a data warning: the empty is the real answer for those keys, and the only thing it costs is the NEXT run’s call. The note names how many of how many keys, and prints the declaration that would keep them. Nothing about the rows is in doubt, so an answer carrying it is never caveated. |
A row is not proof of a complete answer. Rows and totals from an incomplete read are retained as
PARTIAL and accompanied by an incomplete explanation; they are not presented as complete
totals. The diagnostic still prevents a claim that the returned set is complete. An empty result
is not absence when records were fetched but none matched, or when FILTER_STARVED reports that
the query’s filters rejected every fetched
candidate; such a result carries FETCHED_NOT_MATCHED. The exception is a read the engine
reports as complete with an unsent filter applied in the graph: that empty is exact and does not
carry UNPUSHED_FILTER. A direct query result reports how many
source records it fetched and how many nodes it materialized, so a caller can tell “fetched and
unmatched” from “nothing fetched”. An incomplete read that matched nothing, with no failed step, carries
PARTIAL_NOT_MATCHED: it is neither a failure nor evidence of absence.
A month label derived from week-start buckets (for example date.truncate('month', weekStart)
over weekly totals, including suffixed names such as week_start_date) is reported as a total
by week-start, not by calendar month. It does not establish an exact calendar-month total, or that
each labelled week was fetched in full, unless the source filtered the underlying events to that
calendar window before grouping. When the request’s end-date input falls inside a returned
week-start bucket, the result also says that bucket may cover only part of its week, depending on
how the source applies the end bound, so that month’s total may be partial.
A result column whose value the query wrote as a constant is described as written by the query, not returned by the source.
A warning about the result’s columns (a column that holds whole nodes, or a column name that claims more than the query computed) describes how the result was projected. It does not mean the read failed or is incomplete.
A failed read is never cached as an empty result (so a later call with a refreshed token finds
the data); only a genuine, successful “no records” is cacheable — and only when the policy says it
may be, which is what negativeTtlSeconds is for. A diagnostic that leaves a key’s records
unsettled also stops a NEGATION answering over it — see “A short fetch never answers a negation”
below, which lists exactly which of them do.
A hop onto a fetched label with no door reads a lower bound. A declared join is a door in one direction: it says how to fetch the TARGET given the ANCHOR. Walking that edge the other way — holding the target and asking for the anchor’s own records — asks a question no source was given.
MATCH (s:Shop {code: 'north'})-[:HAS_ORDER]->(o:Order)MATCH (o)-[:FOR_CUSTOMER]->(c:Customer)<-[:FOR_CUSTOMER]-(o2:Order)RETURN count(DISTINCT o2) AS nFOR_CUSTOMER is declared on Customer anchored on Order: an order names its customer, and
nothing is ever asked which orders name a given customer. So o2 has no rows of its own. It
matches whichever Order records the query fetched some other way — north’s, through
(:Shop)-[:HAS_ORDER]-> — and every other shop’s orders for that customer are simply absent.
Nothing failed, so nothing else would report it, and n comes back looking like a total. The
arrow as written is not what decides it: the same hop spelled forwards, MATCH (o2:Order)-[:FOR_CUSTOMER]->(c) with c already bound, asks the same unanswerable question and is
flagged the same way.
The query is answered rather than refused, because every row it yields is a true row. The
warning withdraws exactly the claim of completeness, quotes the hop as the query wrote it, and
prints the doors that would give the node rows of its own — here (:Shop)-[:HAS_ORDER]->(o2). A
caller reporting a figure from such a hop must say the answer may be incomplete.
Only fetched labels are in question: a hop among persisted nodes is ordinary Cypher, complete and unflagged. The flag is decided from the query’s shape alone, before anything is fetched, and is withheld in five cases — each a structural proof that the node’s records are all there:
- the node has a door of its own in this query — it is the target of a join the query walks, so its rows are fetched for it, not inherited;
- it is bound before anything is fetched — pinned to a literal, or matched among persisted nodes;
- the join declares how an already-materialized target answers for its anchor
(
materializedKeyField,ridesHub) — the author’s own contract that riding those records IS the whole edge; - every door into the label was walked here over the whole of its anchor’s population —
nothing pinned or filtered on either end and no
LIMITthat could cap the read, so the records materialized are every record there is; - the hop is off an alias that only rebinds nodes another alias bound —
WITH collect(o) AS osthenUNWIND os AS o2introduces no node, soo2ranges over exactly the records fetched foro, and a rebinding is not a match. A hop reached BEYOND such a rebinding is matched afresh against the graph and is flagged as before.
The fourth is judged strictly, and deliberately so: anything the query reads off either end of a door — a predicate, a pin, or merely a projection — leaves the proof unavailable and the hop flagged. The cost of that strictness is a caveat on an answer that happens to be whole, which is the one direction this can safely be wrong in.
A failure costs the keys of the request it failed, and no others. A fan-out over many keys is
many requests — one per key for a source that answers per key, one per maxKeysPerCall batch for a
source that answers in batches — and the request is the unit of the answer, so it is the unit of the
caching decision too. A request that came back is cached for every key it carried, however its
siblings fared; a request that failed leaves every key it carried unresolved, so the next query
asks for exactly those again and nothing else. Three dead keys of two hundred therefore cost three
re-reads on a per-key source, not two hundred.
The split is by request and not by key, because a source that rejects a batch rarely says which of
its ids it choked on: a batch of fifty that answers 500 leaves all fifty unresolved. So a producer
the engine calls a key at a time — one the realm declared batchSafe: false, or whose source is
structurally single-key — has its failures priced per key as a consequence of how it is called,
never as something to declare for. Either shape is flagged the same way: the warnings are what say
the answer is short, whatever was cached.
What a flaky source costs, and it is the same whichever producer kind reached it. A failure is
retried for what it MEANS, never for how the producer was declared: remote/api, graphql,
sparql, publisher and the captured-handler kinds all answer to one table.
| the source said | retried | why |
|---|---|---|
| 429 Too Many Requests | once | it asked us to wait, and waiting is the whole of what it asked for |
| 502, 503, 504 | once | the upstream’s own infrastructure, not the request |
| 408, a read timeout, a dropped or refused connection | once | it never answered; the next call may |
| 400, 422 | no | the source understood the request and refused what it ASKED. Retrying repeats it |
| 401, 403 | no | the credential is not working. The fix is to reconnect, not to ask again |
| 404 | no | the source looked. The answer is short by that key, and is reported as PARTIAL_RESULT, not an outage |
| 500 | no | the source answered about this request, and will answer the same way |
The bound is two attempts per key — one try and one retry — and it is per key, never shared
across a fan-out: ten failing keys cost twenty attempts, not two, and emphatically not two to the
tenth. A retried call is a request that left the machine, so both attempts are billed and
reported in apiCalls; a cost quote prices the happy path, and the gap between the quote and the
meter is the flakiness, readable rather than hidden.
A wait the source asked for outranks ours, inside a window. A Retry-After in delta-seconds is
honoured once, floored at one second (a source asking for “now” is still asking us to wait) and
capped at ten (a source asking for a quarter of an hour cannot hang a query someone is waiting on).
A Retry-After given as an HTTP date is read as no specific ask. With no readable header a throttle
waits about two seconds and the other retryable classes a fraction of one.
Where a Retry-After cannot be read, the retry still happens. A producer reached through a
gateway operation gets the header from the HTTP failure the operation reports, which is also where
its status comes from — so a source that answers over HTTP is honoured exactly. A producer whose
operation failed some other way (a realm handler that threw, a dispatch that was not there) reports
no HTTP response at all, so there is neither a status nor a header: the failure is classified from
its message, and a retry it earns waits the engine’s own bounded wait rather than going unmade. The
degradation is the WAIT, never the attempt.
A declared cost: rate: changes the wait, not the law. A throttled call on a paced bucket slows
every sibling waiting on that bucket and then retries on a fresh slot, which is a better wait than
any single call could compute — and it is still one retry, so the two attempts per key hold.
A statement with a mistake in its own text costs your source nothing to be told so. A Virtual Cypher statement runs only after its fetches, so the cost of a refusal depends on when the mistake can be known. Two kinds, and which one a mistake falls into decides the bill:
-
Settled by reading the statement, under a rule of Cypher itself — a variable the statement binds nowhere (
RETURN q.idwith noqin any clause), two result columns under one name (RETURN p.id AS id, p.ms AS id), aSKIPorLIMITthat is not a row count (LIMIT -1), an aggregate in aWHERE, an implicit grouping, a label or relationship type no vocabulary knows. Refused asINVALID_QUERYwith no producer call, no model call and nothing cached — the anchor’s data is as cold afterwards as it was before, so the next, correct query pays the honest cold price rather than reading a remembered answer. A typo over a two-hundred-anchor hop costs your rate budget nothing. -
Settled only once a row reaches it — a division by zero, an integer overflow, a value of the wrong type, a regex or a date built from fetched data. Whether these raise at all depends on the rows, and the rows are what the fetch produces, so the refusal arrives after the hop has been fetched and the records ARE cached (they are the source’s own answer; only the fetch behind them was avoidable). Guard the value in the statement —
CASE WHEN d = 0 THEN null ELSE n / d END,n / toFloat(d)— and the question costs one fetch instead of two.
An unknown function name is refused with the function named, but after the fetch: which functions exist belongs to the store and the libraries it loads, and refusing up front on a list that might be one version behind would refuse queries that work. The message names it either way.
A short fetch never answers a ranking. A short list is honest under its warning: every row
is right, some are missing. A short ranking is not, because the records left out may be the ones
that belong at the top. So a top-k — ORDER BY … LIMIT k, in a RETURN or a WITH — over a
fetch that came back short answers only when its first k are provably the first k there are,
and is otherwise refused as INVALID_QUERY (reason INCOMPLETE_RANKING), naming the cause, the k
and the levers (raise the cap, narrow the query, or drop the ranking). Two cases are provable:
- A capped read of a source that declares its order. When the fetch stopped at a cap (
maxItems, a page cap, the 25-key ceiling), the unfetched records’ sort keys are unknown, so only the source can vouch: the answer stands when the producer declares the order it returns records in (orderedBy:) and the query’s sort keys are a prefix of that order — same properties, same directions, on the records that producer fetches. A capped prefix of an ordered source is its top. A graph-side filter over a vouched read still answers, flagged partial: short, not wrong.ordering:insources.ymlsays whether a collection is newest-first, not on which field, so it vouches for nothing; a feed declares noorderedBy:, so a capped feed’s ranking always refuses. - Records left out unread under
FOLLOW_CAPPED. Their sort keys are known — only the followed field went unread — so the answer stands when none of them could rank at or above the k-th row. A tie, a missing or incomparable key, or fewer than k rows leave room for them, and refuse; so does a top-k in aWITH, withSKIP, or keyed on another variable, which cannot be compared. When the answer stands, theFOLLOW_CAPPEDnote says the rows are the top k rather than “short”.
A whole key that failed to fetch under a top-k still answers with its PRODUCER_ERROR warning; the
ranking gate does not yet decide that case.
A short fetch never answers a negation. A negation is the one read whose answer GROWS when the fetch comes up short: a key the source did not answer for holds no records as far as the query can see, and “no records” is exactly what the test asks for. So its rows are a superset of the answer and a count over them an upper bound — the opposite of every warning above, each of which says rows may be missing and a count is a floor. A caller acting on the warning as written would under-report.
So a negation over a fetch that did not settle for every key it drove is refused as
INVALID_QUERY (reason UNPROVEN_ABSENCE) rather than answered. It is refused, not flagged,
because no flag can carry it: a reader who takes “may be incomplete” to mean “there may be more”
reads a longer answer as a shorter one, which is worse than no answer at all. This is the same
decision maxAnchors makes one step earlier — a fetch too wide to afford is refused rather than
answered from the keys it could pay for.
- Every spelling of the question is treated alike, because they ask the same thing:
NOT EXISTS { (a)-[:R]->(:T) }, a negated pattern predicateNOT (a)-[:R]->(:T), and the outer-join form —OPTIONAL MATCH (a)-[:R]->(n) … WHERE n IS NULL— which carries noNOTat all. A test on a property (WHERE n.status IS NULL) is not one of them: a property may be null on a record that did arrive, which says nothing about whether the record arrived. - “Did not settle” means absence is unproven for some key: a fetch that failed or was refused
(
FETCH_FAILURE,AUTH_EXPIRED,RATE_LIMITED,FILTER_REJECTED), a read cut short (TRUNCATED,INCOMPLETE_TRAVERSAL), a key never asked (NEEDS_FILTER,UNPUSHED_FILTER, a request the engine declined to send, a case-folded spelling never covered), records fetched but not stored (MATERIALIZATION_FAILED), a node reached only backwards along a join, or a brought child the source gave no identity. Two diagnostics are deliberately NOT in that list, because under them “none” is exact rather than unknown: a definitiveNOT_FOUND(the source says the key does not exist) andFILTER_STARVED(the source answered, and the query’s own narrowing rejected every candidate). ASTALE_COPYis not in it either — a copy is the whole file as the publisher last served it, so an absence in it is exact as of that date. - A complete fetch answers the negation as it always did, including a paged one that read every page and a cap that was declared but never reached. The refusal is about the fetch, not the shape.
- The levers, and each one answers: narrow the query so the fetch settles for every key it
drives (pin fewer keys, or add a filter the source applies); lift whatever stopped the fetch,
which the refusal quotes in the producer’s own words; or ask the same thing without the negation —
the refusal prints the pattern as a plain
MATCH, whose rows are the anchors that DO match, and which a short fetch leaves short rather than wrong, under the warning that says so.
A name that exists nowhere is refused, not answered empty. A query that reads a label or
relationship type that no declared type, virtual join, rule set or stored node in the caller’s
graph knows is refused before it runs, with INVALID_QUERY. The refusal names the pattern where
the name was written and suggests the nearest names the caller can query — an empty answer to a
misspelling would read as “you have none”. A label that is merely empty is different: a declared
type with no instances yet, or a derived label nothing satisfies, answers no rows as before. A
name a statement creates (CREATE, MERGE) is never judged, and property names are not judged
this way, because a type’s declared properties are not a closed list.
When an answer takes longer than the asker will wait. A cold traversal can legitimately run for minutes, which is longer than many callers will hold a connection open. A caller may therefore give a patience budget with its query. If the answer arrives inside that budget it is returned normally and the budget is invisible. If it does not, the result comes back as a run reference instead: an id, a state, and the addresses at which the run can be polled, stopped, or answered.
The guarantees:
- Empty rows beside a run reference mean not yet, never no data. A warning always accompanies them saying so. This is the one misreading that matters, because “no rows” and “no answer yet” look identical to a consumer that only reads rows.
- The work is not abandoned when the caller stops waiting. The same run continues, and its answer is collectable afterwards through the reference.
- A run belongs to whoever started it and is invisible to everyone else.
- Waiting and watching return the same rows. A patience budget changes only who waits, never what the query answers.
- An over-budget-COST query still asks before it spends. Where the cost gate would refuse a query outright, a watching caller instead receives a run in a waiting state carrying the question and its options; nothing is materialized until it is answered, and an unanswered question expires on its own rather than holding resources.
A caller that offers no budget is unaffected: the query runs to completion or to the execution ceiling, exactly as before.
9.1 Fills — driving a large materialization slowly
Section titled “9.1 Fills — driving a large materialization slowly”Some materializations are too big for one query’s patience: a 12-month history over many anchors, an open catalog sweep. A fill drives one durably, slowly, and idempotently:
POST /api/v1/admin/kg/fills { "cypher": "...", "budgetPerTick": 60, "label": "crime history" }GET /api/v1/admin/kg/fills → [{ id, state, ticks, liveCallsTotal, lastError, warning, ... }]DELETE /api/v1/admin/kg/fills/{id} → cancelled (completed work stays cached; nothing rolls back)The engine re-runs the fill’s query on a schedule, each pass under a call budget: at most
budgetPerTick producer calls, stopped CLEANLY when spent — everything fetched stands, nothing
is recorded for the skipped remainder. Because finished work is cached (and closed periods never
expire), each pass advances past everything already done and spends its whole budget on new
ground. A pass that completes with ZERO live calls is the finish line: the fill turns DONE.
Guarantees
- Idempotent and resumable by construction. The fill’s progress IS the caches. Restart the process, cancel and restart the fill, run the same query by hand in between — nothing is done twice and nothing is lost beyond the tick in flight.
- Rate-limited twice over. The per-tick budget bounds each pass; within a pass, every
producer’s own declared
cost.ratepaces its calls. - An error never finishes a fill. A failing pass records its error and stays
RUNNING— transient source trouble is retried on the next tick, and the error is visible on the fill. - A budget-exhausted ordinary query says so. Outside fills, the same budgeted stop surfaces as a warning naming what was not fetched — a partial result is announced, never passed off as complete.
A fill reaches DONE only if its producers keep what they fetch
DONE is a pass that makes no live call at all, and work already done is skipped on the next
pass only while it is still fresh. So a producer whose records expire between passes is re-read
every pass: the fill keeps working, it never runs out of live calls to make, and it never turns
DONE. Nothing is broken — it has simply become a slow permanent poller. Whether a fill can
terminate is decided entirely by its producers’ cache: declarations, and by nothing about the
query, the budget or the schedule.
That is a division of labour, not a limitation:
- A fill converges a COLD materialization of something that does not change once mined: a
closed month of history, a catalog, a backfill of records that are what they are. Declare those
producers so their records outlive the whole sweep —
cache: { kind: immutable }for reference data, or attlin days — and the fill climbs toDONEand stops.periods:already does this for you: a closed period is held effectively forever whatever the producer’s ownttlsays, which is why a 12-month history fill finishes. Only the open period rides the ordinary TTL — and it is also the reason aperiods:fill over a producer with a short TTL does not terminate either, since the open period expires under the sweep while the sweep is still running. - Re-assessing something that keeps changing — a live queue, prices, today’s state — is scheduled
work, not a fill: a Realm Function on a
schedule, or a routine bound to a signal. Those are built to re-run for ever and have no finish line to miss. A fill aimed at a live source only imitates one, at a fill’s pacing and without a schedule’s triggers.
A fill started over producers that cannot outlive a sweep is accepted and carries a warning naming them, visible on the fill in the listing above. It is a warning rather than a refusal because a slow permanent poll is sometimes exactly what someone wants; it should be chosen, not stumbled into.
A realm ships the sweep its own producer needs
The calls above are the operator’s door. A realm that declares an expensive producer should not have
to hope somebody finds out that fills exist, works out the right query and POSTs it: it declares the
sweep as a sweeps: entry on one of its own agents (README, agents/),
each naming a view of the realm’s that takes no arguments. The host then runs it as a fill with
everything above true of it — budgeted, paced, resumable, cancellable, warned about where it cannot
converge.
What a realm gets by declaring one, and what it does not:
- Installing the realm starts nothing. A realm agent arrives off duty with no sponsor and no signature, so there is no sweep to run. The declaration is visible on the agent’s card — the sweep, the view it fills, the budget it asks for — and nothing is bought until somebody sponsors the agent, signs it and puts it on duty. Those are the same three acts every realm agent already needs; a sweep adds no consent step of its own, and the install is not one of them.
- It spends the AGENT’S budget and reaches only what the AGENT may reach — the
budgetandauthority.reachits sponsor signed, never the installing operator’s own allowance. An agent whose reach does not name the API behind a producer the sweep plans calls nothing: the pass is refused before it begins. - Anybody who can stop the agent stops the sweep. Standing the agent down, retiring it or throwing the world’s kill switch stops it; so does removing the realm, which cancels the fill rather than leaving one retrying a query whose realm is gone. Completed work stays cached, so putting the agent back on duty resumes rather than restarts.
- A realm update cannot change what an adopted sweep asks. The view’s text is part of what the sponsor signed, so rewriting it shows as an unsigned change and the signed text keeps running until somebody signs again — as a routine’s body does.
- It is not a schedule. A sweep converges a cold materialization and stops, under exactly the
rules above; re-assessing something that keeps changing is a routine on a
schedule.
name: surveyorjob: Keep the crime history mined, so a question about a street answers from the graph.sweeps: - name: crime-history # stable within the agent; the fill is found by it view: street-crime-sweep # a view of this realm's, taking no arguments budgetPerTick: 60 # optional; the paid calls one pass may make| Sweep field | Required | Meaning |
|---|---|---|
name |
yes | Stable id within the agent. Asking for the same sweep twice is one fill, not two. |
view |
yes | A saved view this world has, declaring no params. Its body is the query each pass runs. |
budgetPerTick |
no | Producer calls, or inline-judgement groups, one pass may buy. Omitted takes the host’s default. |
A sweep whose view the world does not have, or whose view takes arguments, is a need on the agent:
the agent cannot be put on duty until it is fixed, so the declaration is reported before anything runs
rather than failing a pass at a time. A host may cap budgetPerTick, and the number of fills it
advances per tick is the host’s, so several sweeps in one world each advance more slowly.

