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}