Skip to main content

sqlite_graphrag/agent_surface/
gate.rs

1//! The refusals. One place decides when a shaping request cannot be honoured.
2//!
3//! Until v1.2.6 every impossible request was answered with an empty set and
4//! `exit 0`, which a caller cannot tell from "the data is not there". Four
5//! measured shapes produced that:
6//!
7//! * GAP-SG-202 — a projection or predicate key that exists in no element.
8//! * GAP-SG-203 — a predicate aimed at an envelope member, redirected onto an
9//!   array the caller never named and deleting it whole.
10//! * GAP-SG-204 — a knob declared against an envelope with no result array at
11//!   all, so it could not possibly act.
12//! * GAP-SG-201 — a predicate judging only the page the query returned.
13//!
14//! # Why refusals live here and not in the shaping primitives
15//!
16//! [`super::shape`] is stateless and knows nothing about commands, ceilings or
17//! intent; keeping the verdict out of it is what lets one implementation serve
18//! every subcommand. This module is the only one allowed to fail.
19//!
20//! # The fence
21//!
22//! [`super::apply`] runs at OUTPUT time, after the handler already did its work.
23//! Refusing there for a command that changed durable state would report failure
24//! for an operation that succeeded, and a caller that retries a succeeded
25//! `remember` writes the memory twice. So a surface marked
26//! [`super::AgentSurface::mutates`] is never refused — it is annotated instead.
27//! That is a deliberate asymmetry: a diagnostic lost is cheaper than data
28//! duplicated.
29
30use super::universe::{self, CeilingKind, FilterScope, QueryCeiling};
31use super::vocabulary::{KeyOrigin, Scope};
32use super::AgentSurface;
33use crate::errors::AppError;
34use crate::i18n::validation as msg;
35
36/// What resolution learned, for the `agent_surface` record.
37#[derive(Debug, Default)]
38pub struct Findings {
39    /// Keys asked for by `--select` that the shaped payload can actually carry.
40    pub resolved_keys: Vec<String>,
41    /// Keys asked for by `--select` that nothing in scope carries.
42    ///
43    /// Non-empty here means the projection succeeded PARTIALLY. A caller that
44    /// gets fewer fields than it asked for learns why from this list instead of
45    /// concluding the records were incomplete.
46    pub unresolved_keys: Vec<String>,
47    /// Names the caller might have meant, for the keys that failed.
48    ///
49    /// Carried as DATA rather than folded into a sentence: a refusal message is
50    /// localized, so parsing the suggestion out of it would mean parsing prose in
51    /// one of two languages. `rules-rust-cli-com-clap-io-exitcodes-erros` asks a
52    /// refusal to carry a corrective action, and the agent-native contract asks
53    /// the action to be machine-readable.
54    pub key_suggestions: Vec<String>,
55    /// Whether the suggestion vocabulary was sampled rather than exhaustive.
56    ///
57    /// Never weakens a verdict — [`Scope::classify`] always scans every element.
58    /// It qualifies the ADVICE: a short list under a partial vocabulary means
59    /// "the sampler stopped", not "nothing resembles your key".
60    pub vocabulary_partial: bool,
61}
62
63impl Findings {
64    /// `true` when at least one requested key failed to resolve.
65    pub fn is_partial(&self) -> bool {
66        !self.unresolved_keys.is_empty()
67    }
68}
69
70/// Label used when the result array is the envelope itself (a top-level array).
71const ANONYMOUS_ARRAY: &str = "results";
72
73/// Argv spellings reported under `discarded_flags`.
74///
75/// Named rather than inlined because they are a wire contract now: a consumer
76/// matches on them, so a typo in one branch would be a silent contract break.
77/// They are argv tokens, never prose, so they are NOT localized.
78const FILTER_FLAG: &str = "--filter";
79/// Argv spelling of the ordering knob.
80const SORT_FLAG: &str = "--sort";
81/// Argv spelling of the deduplication knob.
82const DEDUPE_FLAG: &str = "--dedupe-by";
83/// Argv spelling of the projection knob.
84const SELECT_FLAG: &str = "--select";
85/// Argv spelling of the knob that replaces the payload with a count.
86const COUNT_ONLY_FLAG: &str = "--count-only";
87/// Argv spelling of the envelope byte ceiling.
88const MAX_OUTPUT_BYTES_FLAG: &str = "--max-output-bytes";
89/// Argv spelling of the cap on emitted result elements.
90const MAX_ITEMS_FLAG: &str = "--max-items";
91
92/// Builds a refusal that names, as data, the flags it could not honour.
93///
94/// `rules-rust-cli-stdin-stdout-silent-discard` asks the error JSON to carry
95/// `discarded_flags`. Every refusal below is by definition an argument the
96/// caller typed and the binary did not apply, so each one fills it — reading
97/// which of your own flags were dropped must never require parsing a sentence.
98fn refuse(message: String, discarded_flags: Vec<String>) -> AppError {
99    AppError::Usage {
100        message,
101        discarded_flags,
102    }
103}
104
105/// Decides whether `surface` may be applied to the envelope described by `scope`.
106///
107/// # Errors
108/// Returns [`AppError::Usage`] — exit `2` — when the request cannot be honoured
109/// as written. Every message names the offending flag and a way forward.
110pub fn evaluate(
111    surface: &AgentSurface,
112    scope: &Scope,
113    array_key: Option<&str>,
114    has_array: bool,
115    ceiling: Option<&universe::QueryCeiling>,
116) -> Result<Findings, AppError> {
117    let mut findings = Findings::default();
118
119    // The fence. See the module docs for why this is unconditional.
120    if surface.mutates {
121        return Ok(findings);
122    }
123
124    // First, because "this is a stream" is a fact about the SHAPE of the output
125    // and outranks every question about the set the query returned.
126    refuse_whole_set_knobs_on_a_stream(surface)?;
127    refuse_inert_knobs(surface, has_array)?;
128    refuse_a_predicate_over_a_page(surface, ceiling)?;
129    refuse_a_count_over_a_page(surface, ceiling)?;
130
131    if surface.allow_unknown_keys {
132        return Ok(findings);
133    }
134
135    // An EMPTY result array is absence of evidence, not evidence of absence.
136    // `related --select name` over a seed with no neighbours found zero elements,
137    // so every key was unresolvable and the refusal even suggested the key it had
138    // just rejected. Refusing on an empty set inverts this gate into the very
139    // false negative it exists to remove: the caller would read "your key is
140    // wrong" where the truth is "there were no rows".
141    //
142    // A scalar envelope is excluded because it has no elements BY SHAPE and
143    // resolves against the envelope itself, which is real evidence.
144    if has_array && scope.is_empty() {
145        return Ok(findings);
146    }
147
148    let array_name = array_key.unwrap_or(ANONYMOUS_ARRAY);
149    for expr in &surface.filters {
150        refuse_unusable_key(scope, FILTER_FLAG, &expr.key(), array_name)?;
151    }
152    if let Some(key) = &surface.sort {
153        refuse_unusable_key(scope, SORT_FLAG, key, array_name)?;
154    }
155    if let Some(key) = &surface.dedupe_by {
156        refuse_unusable_key(scope, DEDUPE_FLAG, key, array_name)?;
157    }
158
159    // An array elected by the FALLBACK is not a declared result set: `stats`
160    // carries `namespaces`, which is not in `AGENT_SURFACE_RESULT_KEYS`, so the
161    // surface picked it for want of anything better. Binding projection to it
162    // refused the documented `stats --json --select total_memories`, whose key
163    // is a top-level member — the same misdirection GAP-SG-203 describes for
164    // predicates, reaching `--select`.
165    let declared_array = array_key.is_some_and(super::is_declared_result_array);
166    resolve_projection(surface, scope, has_array && declared_array, &mut findings)?;
167    Ok(findings)
168}
169
170/// GAP-SG-215: the same verdict, for a payload that is a STREAM of records.
171///
172/// [`evaluate`] answers about one complete envelope, and every question it asks
173/// past the refusals — which member is the result array, is that array declared,
174/// did a ceiling cut it — is a question about envelope structure a stream does
175/// not have. Running it per line is precisely the defect this closes:
176/// `--select name export` projected three records correctly and then failed on
177/// the fourth line, the summary, because that line is a different shape and was
178/// judged as though it were a record.
179///
180/// So a stream gets the two decisions that ARE about records, sharing the very
181/// functions [`evaluate`] uses:
182///
183/// * the stream refusals, so an unusable knob fails before the first byte
184/// * projection resolution against the record vocabulary, so `--select` is
185///   answered ONCE for the whole stream instead of once per line
186///
187/// `scope` is built from a bounded prefix of the records rather than from all of
188/// them. [`super::vocabulary::Scope::classify`] scans every element it is given,
189/// and giving it every element of a 100 000-row export would mean holding the
190/// whole corpus as `Value` — measured at ~24 KB per record, so ~2.4 GB. The
191/// prefix is the honest bound, and `vocabulary_partial` on the trailer declares
192/// that it was one. It is still strictly stronger than what it replaces, which
193/// judged each line in isolation against no vocabulary at all.
194///
195/// # Errors
196/// Returns [`AppError::Usage`] — exit `2` — before the caller has written
197/// anything, which is the property the per-line path could not offer.
198pub fn evaluate_stream(surface: &AgentSurface, scope: &Scope) -> Result<Findings, AppError> {
199    let mut findings = Findings::default();
200
201    // The fence, for the same reason as in `evaluate`: `ingest` streams and
202    // writes, and refusing after a write is what makes a caller retry a
203    // succeeded operation.
204    if surface.mutates {
205        return Ok(findings);
206    }
207
208    refuse_whole_set_knobs(surface)?;
209    refuse_a_predicate_on_a_stream(surface)?;
210
211    if surface.allow_unknown_keys || scope.is_empty() {
212        return Ok(findings);
213    }
214    // `true` because a stream IS its records: there is no envelope for a key to
215    // resolve against instead, so projection always targets the elements.
216    resolve_projection(surface, scope, true, &mut findings)?;
217    Ok(findings)
218}
219
220/// GAP-SG-201: the ceiling, when it hid rows the caller is about to report on.
221///
222/// Returns `None` — meaning "nothing to refuse" — in the three cases that are
223/// not a hidden page:
224///
225/// * No ceiling was declared, so the command never paged anything.
226/// * A [`CeilingKind::TopK`] bound, which IS the answer rather than a truncation
227///   of one — see [`super::universe`] for why that distinction is load-bearing.
228/// * A ceiling that cut nothing, because a `--limit` wider than the corpus left
229///   the caller looking at the whole universe.
230///
231/// And in the one case where the caller already answered the question:
232/// [`FilterScope::Page`] declares the narrower reading on purpose.
233///
234/// # Why this is a function and not two copies of four conditions
235///
236/// GAP-SG-201 shipped with the predicate refusal wired and the count refusal
237/// written, translated and never called. Spelling the escapes out twice is how
238/// the second copy would drift from the first the next time one is added — the
239/// same "two hand-written lists with no contract between them" that produced the
240/// split relation vocabulary. One implementation, both readers.
241///
242/// The ceiling arrives as an ARGUMENT rather than being read here. It lives in a
243/// process-wide `OnceLock`, which is correct for a one-shot binary and wrong for
244/// a test binary: `OnceLock` offers no reset for a `static` — `take` and every
245/// `get_mut` need `&mut self` — so two tests in one process could never state
246/// different ceilings, and no refusal in this family had a test at all. Taking
247/// it as a parameter makes each test state its own premise. [`super::apply_with_target`]
248/// already does exactly this with the resolved target, for the same reason.
249fn a_truncated_page<'a>(
250    surface: &AgentSurface,
251    ceiling: Option<&'a QueryCeiling>,
252) -> Option<&'a QueryCeiling> {
253    let ceiling = ceiling?;
254    if ceiling.kind != CeilingKind::Pagination || !ceiling.truncated_the_universe() {
255        return None;
256    }
257    if surface.filter_scope == Some(FilterScope::Page) {
258        return None;
259    }
260    Some(ceiling)
261}
262
263/// GAP-SG-201: a predicate must not report on a set the query already cut.
264///
265/// `--sort` and `--select` are absent by design: reordering or projecting the
266/// rows you received claims nothing about the rows you did not.
267fn refuse_a_predicate_over_a_page(
268    surface: &AgentSurface,
269    ceiling: Option<&QueryCeiling>,
270) -> Result<(), AppError> {
271    if surface.filters.is_empty() {
272        return Ok(());
273    }
274    let Some(ceiling) = a_truncated_page(surface, ceiling) else {
275        return Ok(());
276    };
277    let total = ceiling.universe_total.unwrap_or(ceiling.applied);
278    Err(refuse(
279        msg::filter_scope_is_a_page(ceiling.applied, total, ceiling.source.as_str()),
280        vec![FILTER_FLAG.to_string()],
281    ))
282}
283
284/// GAP-SG-201: a bare count over a page reads as the inventory.
285///
286/// The sibling above turns on `--filter`, so `--count-only` ALONE slipped under
287/// it: `--count-only graph entities` answered `50` over a universe of 107 111
288/// with `exit 0`, on a command line that mentioned no limit at all, because that
289/// subcommand caps at 50 by itself. A caller that reads `{"count": 50}` cannot
290/// tell that from a corpus which really held fifty.
291///
292/// `--count-only` earns its own refusal rather than an extra clause on the
293/// sibling because the two are independent knobs: a count is a claim about the
294/// size of a set, which is exactly the claim a page cannot support, whether or
295/// not a predicate was also given.
296fn refuse_a_count_over_a_page(
297    surface: &AgentSurface,
298    ceiling: Option<&QueryCeiling>,
299) -> Result<(), AppError> {
300    if !surface.count_only {
301        return Ok(());
302    }
303    let Some(ceiling) = a_truncated_page(surface, ceiling) else {
304        return Ok(());
305    };
306    let total = ceiling.universe_total.unwrap_or(ceiling.applied);
307    Err(refuse(
308        msg::count_only_over_a_page(ceiling.applied, total),
309        vec![COUNT_ONLY_FLAG.to_string()],
310    ))
311}
312
313/// GAP-SG-209: a knob that needs the whole set was aimed at a stream.
314///
315/// `export` emits one self-contained record per line, and [`super::apply`] runs
316/// once per emitted envelope. `--count-only export --limit 10` therefore
317/// answered with ELEVEN `{"count":1}` lines — one per record plus the summary —
318/// rather than one count of ten. The other three are the same mistake in a
319/// quieter register: a byte budget spent per line is not a budget on the output,
320/// and an ordering or a dedup that cannot see the next line is not one at all.
321///
322/// `--select` and `--truncate-content` are absent by design. Each acts WITHIN one
323/// record and means exactly the same thing whether the record arrives alone or in
324/// a stream, so refusing them would remove a working feature to cure nothing.
325/// GAP-SG-215 makes that pair the WHOLE of what a stream accepts.
326///
327/// `--max-items` joined the list in v1.2.8. It was measured accepted and inert —
328/// `--max-items 2 export --limit 5` answered with all five records and `exit 0` —
329/// because it caps elements INSIDE an envelope and a record line carries no array
330/// to cap. The corrective action is the query's own `--limit`, which the message
331/// names.
332///
333/// Reached only for a read-only stream in practice: the `mutates` fence above
334/// returns first for `ingest`, which is the other streaming subcommand, and
335/// refusing after a write is the hazard that fence exists to prevent.
336fn refuse_whole_set_knobs_on_a_stream(surface: &AgentSurface) -> Result<(), AppError> {
337    if !surface.streamed {
338        return Ok(());
339    }
340    refuse_whole_set_knobs(surface)?;
341    refuse_a_predicate_on_a_stream(surface)
342}
343
344/// The body of the refusal above, with the "is this a stream" test already made.
345///
346/// Split out so [`evaluate_stream`] cannot forget it. That path is only ever
347/// reached from a stream emitter, so re-testing `surface.streamed` there would
348/// make the refusal depend on a flag being wired rather than on the caller being
349/// a stream — the same "guard anchored to a proxy instead of the real property"
350/// that GAP-SG-206 cost two attempts to unlearn.
351fn refuse_whole_set_knobs(surface: &AgentSurface) -> Result<(), AppError> {
352    let mut discarded = Vec::new();
353    if surface.count_only {
354        discarded.push(COUNT_ONLY_FLAG.to_string());
355    }
356    if surface.sort.is_some() {
357        discarded.push(SORT_FLAG.to_string());
358    }
359    if surface.dedupe_by.is_some() {
360        discarded.push(DEDUPE_FLAG.to_string());
361    }
362    if surface.max_output_bytes > 0 {
363        discarded.push(MAX_OUTPUT_BYTES_FLAG.to_string());
364    }
365    if surface.max_items > 0 {
366        discarded.push(MAX_ITEMS_FLAG.to_string());
367    }
368    if discarded.is_empty() {
369        return Ok(());
370    }
371    Err(refuse(msg::knob_needs_a_whole_set(&discarded), discarded))
372}
373
374/// GAP-SG-215: `--filter` on a stream would desynchronise the trailer's tally.
375///
376/// Filtering per record is mechanically possible, which is exactly why this
377/// needs its own refusal rather than a sixth entry in the list above: the reason
378/// is not "it cannot act". It is that the COMMAND counts the records — `export`
379/// reports `exported`, `ingest` reports `files_succeeded` — and those numbers are
380/// computed before the surface ever sees a line. A predicate that silently
381/// dropped records would leave the trailer claiming a count the stream never
382/// emitted, which is a worse failure than refusing: the caller would have no way
383/// to notice.
384///
385/// Until v1.2.8 this refused anyway, by accident, through
386/// [`refuse_inert_knobs`] — a record line carries no result array, so the
387/// predicate was rejected as having nothing to act on. Right verdict, wrong
388/// reason, and a reason that would have stopped being true the moment a stream
389/// emitted a record that happened to contain an array.
390fn refuse_a_predicate_on_a_stream(surface: &AgentSurface) -> Result<(), AppError> {
391    if surface.filters.is_empty() {
392        return Ok(());
393    }
394    let discarded = vec![FILTER_FLAG.to_string()];
395    Err(refuse(msg::filter_would_desync_a_tally(), discarded))
396}
397
398/// GAP-SG-204: a knob with nothing to act on is an argument silently discarded.
399fn refuse_inert_knobs(surface: &AgentSurface, has_array: bool) -> Result<(), AppError> {
400    if has_array {
401        return Ok(());
402    }
403    // `--select` is deliberately absent: on an envelope with no result array it
404    // projects the envelope itself, which is a real effect.
405    let mut discarded = Vec::new();
406    if !surface.filters.is_empty() {
407        discarded.push(FILTER_FLAG.to_string());
408    }
409    if surface.sort.is_some() {
410        discarded.push(SORT_FLAG.to_string());
411    }
412    if surface.dedupe_by.is_some() {
413        discarded.push(DEDUPE_FLAG.to_string());
414    }
415    if discarded.is_empty() {
416        return Ok(());
417    }
418    Err(refuse(msg::knob_without_target(&discarded), discarded))
419}
420
421/// GAP-SG-202 and GAP-SG-203: a predicate key must address the elements it will
422/// be evaluated against.
423fn refuse_unusable_key(
424    scope: &Scope,
425    flag: &str,
426    key: &str,
427    array_name: &str,
428) -> Result<(), AppError> {
429    match scope.classify(key) {
430        KeyOrigin::Element => Ok(()),
431        KeyOrigin::EnvelopeOnly => Err(refuse(
432            msg::key_is_envelope_only(flag, key, array_name),
433            vec![flag.to_string()],
434        )),
435        KeyOrigin::Absent => Err(refuse(
436            msg::key_absent(flag, key, &scope.suggestions(key)),
437            vec![flag.to_string()],
438        )),
439    }
440}
441
442/// Classifies every `--select` key and refuses only when NONE of them resolve.
443///
444/// Partial success stays successful on purpose: an agent projecting six fields
445/// across a heterogeneous result set still gets a useful answer when one field
446/// is missing, and `unresolved_keys` tells it which. Refusing the whole request
447/// there would trade a silent omission for a needless failure.
448fn resolve_projection(
449    surface: &AgentSurface,
450    scope: &Scope,
451    has_array: bool,
452    findings: &mut Findings,
453) -> Result<(), AppError> {
454    if surface.select.is_empty() {
455        return Ok(());
456    }
457    // Projection targets the elements when there are elements, and the envelope
458    // otherwise, so what counts as resolvable follows the shape of the payload.
459    let usable = if has_array {
460        KeyOrigin::Element
461    } else {
462        KeyOrigin::EnvelopeOnly
463    };
464    for key in &surface.select {
465        if scope.classify(key) == usable {
466            findings.resolved_keys.push(key.clone());
467        } else {
468            findings.unresolved_keys.push(key.clone());
469        }
470    }
471    if !findings.resolved_keys.is_empty() {
472        // Partial success stays successful, so the advice has to travel WITH the
473        // answer: this is the only path where a caller receives fewer fields than
474        // it asked for and no error envelope explains why.
475        if !findings.unresolved_keys.is_empty() {
476            findings.vocabulary_partial = scope.vocabulary_is_partial();
477            let mut seen = std::collections::BTreeSet::new();
478            for key in &findings.unresolved_keys {
479                for candidate in scope.suggestions(key) {
480                    seen.insert(candidate);
481                }
482            }
483            findings.key_suggestions = seen.into_iter().collect();
484        }
485        return Ok(());
486    }
487    let suggestions = surface
488        .select
489        .first()
490        .map(|key| scope.suggestions(key))
491        .unwrap_or_default();
492    Err(refuse(
493        msg::select_fully_unresolved(&surface.select, &suggestions),
494        vec![SELECT_FLAG.to_string()],
495    ))
496}