Skip to main content

fraiseql_cli/schema/validator/
schema_validator.rs

1//! `SchemaValidator` — validates an `IntermediateSchema` with detailed error reporting.
2
3use std::collections::HashSet;
4
5use anyhow::Result;
6use tracing::{debug, info};
7
8use super::{
9    sql_identifier::validate_sql_identifier,
10    types::{ErrorSeverity, ValidationError, ValidationReport},
11};
12use crate::schema::intermediate::IntermediateSchema;
13
14/// Strip GraphQL type modifiers (`!`, `[]`) to extract the base type name.
15///
16/// Examples: `"UUID!"` → `"UUID"`, `"[User!]!"` → `"User"`, `"String"` → `"String"`
17pub(crate) fn extract_base_type(type_str: &str) -> &str {
18    let s = type_str.trim();
19    let s = s.trim_start_matches('[').trim_end_matches(']');
20    let s = s.trim_end_matches('!').trim_start_matches('!');
21    let s = s.trim_start_matches('[').trim_end_matches(']');
22    let s = s.trim_end_matches('!');
23    s.trim()
24}
25
26/// Enhanced schema validator
27pub struct SchemaValidator;
28
29impl SchemaValidator {
30    /// Validate an intermediate schema with detailed error reporting
31    ///
32    /// # Errors
33    ///
34    /// Currently infallible; always returns `Ok` containing the report.
35    /// The `Result` return type is reserved for future validation that may
36    /// require fallible I/O.
37    #[allow(clippy::cognitive_complexity)] // Reason: comprehensive schema validation with many cross-field constraint checks
38    pub fn validate(schema: &IntermediateSchema) -> Result<ValidationReport> {
39        info!("Validating schema structure");
40
41        let mut report = ValidationReport::default();
42
43        // Build type registry.
44        //
45        // `enumerate()` rather than `type_names.len()` for the duplicate path: the count of
46        // unique names seen so far is not the offending element's index, and the two diverge
47        // exactly when there are duplicates — the one case this diagnostic exists for. With
48        // `[A, B, A, A]` the count is stuck at 2, so the old path reported `types[2]` twice
49        // and never named element 3 (#724 item 3).
50        let mut type_names = HashSet::new();
51        for (idx, type_def) in schema.types.iter().enumerate() {
52            if type_names.contains(&type_def.name) {
53                report.errors.push(ValidationError {
54                    message:    format!("Duplicate type name: '{}'", type_def.name),
55                    path:       format!("types[{idx}].name"),
56                    severity:   ErrorSeverity::Error,
57                    suggestion: Some("Type names must be unique".to_string()),
58                });
59            }
60            type_names.insert(type_def.name.clone());
61        }
62
63        // A type's subscription policy must name a single-level field of the type, or the
64        // delivery path would match nothing (or refuse every subscriber) at runtime.
65        for (idx, type_def) in schema.types.iter().enumerate() {
66            let Some(policy) = &type_def.subscription_policy else {
67                continue;
68            };
69            let path = format!("types[{idx}].subscription_policy");
70            if let Err(reason) = policy.validate() {
71                report.errors.push(ValidationError {
72                    message: format!("Type '{}': {reason}", type_def.name),
73                    path,
74                    severity: ErrorSeverity::Error,
75                    suggestion: None,
76                });
77                continue;
78            }
79            if !type_def.fields.iter().any(|field| policy.owner_is_stored_key_of(&field.name)) {
80                report.errors.push(ValidationError {
81                    message: format!(
82                        "Type '{}': subscription_policy.owner_path `{}` names no field of the type",
83                        type_def.name, policy.owner_path
84                    ),
85                    path,
86                    severity: ErrorSeverity::Error,
87                    suggestion: Some(format!(
88                        "use one of: {}",
89                        type_def
90                            .fields
91                            .iter()
92                            .map(|f| f.name.as_str())
93                            .collect::<Vec<_>>()
94                            .join(", ")
95                    )),
96                });
97            }
98        }
99
100        // Add input types — valid as mutation argument types (fraiseql/fraiseql#190)
101        for input_type in &schema.input_types {
102            type_names.insert(input_type.name.clone());
103        }
104
105        // Add union type names — valid as mutation/query return types
106        for union_def in &schema.unions {
107            type_names.insert(union_def.name.clone());
108        }
109
110        // Add interfaces — valid as field and return types (GraphQL spec §3.7)
111        for interface in &schema.interfaces {
112            type_names.insert(interface.name.clone());
113        }
114
115        // Add enums — valid as field and argument types
116        for enum_def in &schema.enums {
117            type_names.insert(enum_def.name.clone());
118        }
119
120        // The **composite** types, as GraphQL defines them: objects, interfaces and
121        // unions. A mutation's return type must be one of these (#1358); every other
122        // position accepts the wider `type_names`.
123        let composite_type_names: HashSet<String> = schema
124            .types
125            .iter()
126            .map(|t| t.name.clone())
127            .chain(schema.unions.iter().map(|u| u.name.clone()))
128            .chain(schema.interfaces.iter().map(|i| i.name.clone()))
129            .collect();
130
131        // Add built-in scalars, and the engine's rich scalars (#1530): the converter
132        // resolves both without a declaration, so both are known names here.
133        for scalar in crate::schema::builtin_scalar_names() {
134            type_names.insert(scalar.to_string());
135        }
136        for scalar in fraiseql_core::schema::RICH_SCALARS {
137            type_names.insert((*scalar).to_string());
138        }
139
140        // Add **declared** custom scalars.
141        //
142        // This used to register every type name appearing in any object field as an implicit
143        // custom scalar, which made field-type typos invisible: `"type": "Strng"` validated
144        // cleanly, and the typo then also legalized `Strng` as a query return type, so one
145        // mistake propagated instead of being caught (#724 item 2). Only names the author
146        // actually declared are scalars now; an undeclared field type is reported below.
147        for scalar in schema.custom_scalars.iter().flatten() {
148            type_names.insert(scalar.name.clone());
149        }
150
151        // Refuse field types that resolve to nothing declared (#1530).
152        //
153        // #724 made this a warning, for a `--schema-dir` author whose custom scalar might be
154        // declared in a file this validator could not see. Every workflow merges its files
155        // before validating, so it sees them all; and compiled anyway, the name became an
156        // object reference the server answered with no value, under a `200`. A typo'd
157        // scalar lost data silently, one warning among dozens.
158        for (type_idx, type_def) in schema.types.iter().enumerate() {
159            for (field_idx, field) in type_def.fields.iter().enumerate() {
160                let base = extract_base_type(&field.field_type);
161                if base.is_empty() || type_names.contains(base) {
162                    continue;
163                }
164                report.errors.push(ValidationError {
165                    message:    format!(
166                        "Field '{}.{}' has type '{base}', which is not a declared type, enum, \
167                         interface, union, input type, built-in scalar or custom scalar",
168                        type_def.name, field.name
169                    ),
170                    path:       format!("types[{type_idx}].fields[{field_idx}].type"),
171                    severity:   ErrorSeverity::Error,
172                    suggestion: Some(format!(
173                        "{} If '{base}' is a custom scalar, declare it in `custom_scalars`.",
174                        Self::suggest_similar_type(base, &type_names)
175                    )),
176                });
177            }
178        }
179
180        // Validate queries
181        let mut query_names = HashSet::new();
182        for (idx, query) in schema.queries.iter().enumerate() {
183            debug!("Validating query: {}", query.name);
184
185            // Check for duplicate query names
186            if query_names.contains(&query.name) {
187                report.errors.push(ValidationError {
188                    message:    format!("Duplicate query name: '{}'", query.name),
189                    path:       format!("queries[{idx}].name"),
190                    severity:   ErrorSeverity::Error,
191                    suggestion: Some("Query names must be unique".to_string()),
192                });
193            }
194            query_names.insert(query.name.clone());
195
196            // Validate return type exists (strip ! and [] modifiers)
197            let base_return = extract_base_type(&query.return_type);
198            if !type_names.contains(base_return) {
199                report.errors.push(ValidationError {
200                    message:    format!(
201                        "Query '{}' references unknown type '{}'",
202                        query.name, base_return
203                    ),
204                    path:       format!("queries[{idx}].return_type"),
205                    severity:   ErrorSeverity::Error,
206                    suggestion: Some(Self::suggest_similar_type(base_return, &type_names)),
207                });
208            }
209
210            // Validate argument types
211            for (arg_idx, arg) in query.arguments.iter().enumerate() {
212                let base_arg = extract_base_type(&arg.arg_type);
213                if !type_names.contains(base_arg) {
214                    report.errors.push(ValidationError {
215                        message:    format!(
216                            "Query '{}' argument '{}' references unknown type '{}'",
217                            query.name, arg.name, base_arg
218                        ),
219                        path:       format!("queries[{idx}].arguments[{arg_idx}].type"),
220                        severity:   ErrorSeverity::Error,
221                        suggestion: Some(Self::suggest_similar_type(base_arg, &type_names)),
222                    });
223                }
224            }
225
226            // Validate sql_source is a safe SQL identifier
227            if let Some(sql_source) = &query.sql_source {
228                if let Err(e) = validate_sql_identifier(
229                    sql_source,
230                    "sql_source",
231                    &format!("Query.{}", query.name),
232                ) {
233                    report.errors.push(e);
234                }
235            }
236
237            // Warning for queries without SQL source.
238            //
239            // A function-backed query (#1329) has none by construction and is not
240            // missing anything — warning there would train authors to ignore the
241            // warning that still matters for the SQL-backed case.
242            if query.sql_source.is_none() && query.function.is_none() && query.returns_list {
243                report.errors.push(ValidationError {
244                    message:    format!(
245                        "Query '{}' returns a list but has no sql_source",
246                        query.name
247                    ),
248                    path:       format!("queries[{idx}]"),
249                    severity:   ErrorSeverity::Warning,
250                    suggestion: Some("Add sql_source for SQL-backed queries".to_string()),
251                });
252            }
253        }
254
255        // Validate mutations
256        let mut mutation_names = HashSet::new();
257        for (idx, mutation) in schema.mutations.iter().enumerate() {
258            debug!("Validating mutation: {}", mutation.name);
259
260            // Check for duplicate mutation names
261            if mutation_names.contains(&mutation.name) {
262                report.errors.push(ValidationError {
263                    message:    format!("Duplicate mutation name: '{}'", mutation.name),
264                    path:       format!("mutations[{idx}].name"),
265                    severity:   ErrorSeverity::Error,
266                    suggestion: Some("Mutation names must be unique".to_string()),
267                });
268            }
269            mutation_names.insert(mutation.name.clone());
270
271            // Validate return type exists (strip ! and [] modifiers)
272            let base_return = extract_base_type(&mutation.return_type);
273            if type_names.contains(base_return) {
274                // …and that it is **composite** (#1358).
275                //
276                // A mutation's result is projected out of the `app.mutation_response`
277                // envelope, which is entity-shaped by construction, so a leaf return
278                // type has no coherent meaning under it. Declaring one was accepted:
279                // `type_names` registers enums, built-in scalars and declared custom
280                // scalars alongside the composite types, because every *other*
281                // position — field types, argument types — legitimately takes them.
282                //
283                // What the runtime then did with `mutation { setStatus }` is the
284                // reason this is an error rather than a warning. A leaf return type
285                // needs no selection set, so GraphQL § 5.3.3 (#1357) correctly does
286                // not refuse the document, and the empty set reaches the projector —
287                // where an empty set means "no field filtering". The response was the
288                // whole stored entity object, under a field the schema types as an
289                // enum, with the #423 field authorizer never consulted because a leaf
290                // type has no field list to find a gated field in.
291                if !composite_type_names.contains(base_return) {
292                    report.errors.push(ValidationError {
293                        message:    format!(
294                            "Mutation '{}' returns '{}', which is not a composite type. A                              mutation's return type must be an object, interface or union.",
295                            mutation.name, base_return
296                        ),
297                        path:       format!("mutations[{idx}].return_type"),
298                        severity:   ErrorSeverity::Error,
299                        suggestion: Some(
300                            "Return the entity type the mutation writes, or a union of                              success and error variants."
301                                .to_string(),
302                        ),
303                    });
304                }
305            } else {
306                report.errors.push(ValidationError {
307                    message:    format!(
308                        "Mutation '{}' references unknown type '{}'",
309                        mutation.name, base_return
310                    ),
311                    path:       format!("mutations[{idx}].return_type"),
312                    severity:   ErrorSeverity::Error,
313                    suggestion: Some(Self::suggest_similar_type(base_return, &type_names)),
314                });
315            }
316
317            // Validate argument types
318            for (arg_idx, arg) in mutation.arguments.iter().enumerate() {
319                let base_arg = extract_base_type(&arg.arg_type);
320                if !type_names.contains(base_arg) {
321                    report.errors.push(ValidationError {
322                        message:    format!(
323                            "Mutation '{}' argument '{}' references unknown type '{}'",
324                            mutation.name, arg.name, base_arg
325                        ),
326                        path:       format!("mutations[{idx}].arguments[{arg_idx}].type"),
327                        severity:   ErrorSeverity::Error,
328                        suggestion: Some(Self::suggest_similar_type(base_arg, &type_names)),
329                    });
330                }
331            }
332
333            // Validate sql_source is a safe SQL identifier
334            if let Some(sql_source) = &mutation.sql_source {
335                if let Err(e) = validate_sql_identifier(
336                    sql_source,
337                    "sql_source",
338                    &format!("Mutation.{}", mutation.name),
339                ) {
340                    report.errors.push(e);
341                }
342            }
343
344            // Warn about inject_params ordering contract
345            if !mutation.inject.is_empty() {
346                let inject_names: Vec<&str> = mutation.inject.keys().map(String::as_str).collect();
347                let fn_name = mutation.sql_source.as_deref().unwrap_or("<unknown>");
348                report.errors.push(ValidationError {
349                    message:    format!(
350                        "Mutation '{}' has inject params {:?}. \
351                         These are appended as the LAST positional arguments to \
352                         `{fn_name}`. Your SQL function MUST declare injected \
353                         parameters last, after all client-provided arguments.",
354                        mutation.name, inject_names,
355                    ),
356                    path:       format!("Mutation.{}", mutation.name),
357                    severity:   ErrorSeverity::Warning,
358                    suggestion: None,
359                });
360            }
361        }
362
363        // Validate observers
364        if let Some(observers) = &schema.observers {
365            let mut observer_names = HashSet::new();
366            for (idx, observer) in observers.iter().enumerate() {
367                debug!("Validating observer: {}", observer.name);
368
369                // Check for duplicate observer names
370                if observer_names.contains(&observer.name) {
371                    report.errors.push(ValidationError {
372                        message:    format!("Duplicate observer name: '{}'", observer.name),
373                        path:       format!("observers[{idx}].name"),
374                        severity:   ErrorSeverity::Error,
375                        suggestion: Some("Observer names must be unique".to_string()),
376                    });
377                }
378                observer_names.insert(observer.name.clone());
379
380                // Validate entity type exists
381                if !type_names.contains(&observer.entity) {
382                    report.errors.push(ValidationError {
383                        message:    format!(
384                            "Observer '{}' references unknown entity '{}'",
385                            observer.name, observer.entity
386                        ),
387                        path:       format!("observers[{idx}].entity"),
388                        severity:   ErrorSeverity::Error,
389                        suggestion: Some(Self::suggest_similar_type(&observer.entity, &type_names)),
390                    });
391                }
392
393                // Validate event type
394                let valid_events = ["INSERT", "UPDATE", "DELETE"];
395                if !valid_events.contains(&observer.event.as_str()) {
396                    report.errors.push(ValidationError {
397                        message:    format!(
398                            "Observer '{}' has invalid event '{}'. Must be INSERT, UPDATE, or DELETE",
399                            observer.name, observer.event
400                        ),
401                        path:       format!("observers[{idx}].event"),
402                        severity:   ErrorSeverity::Error,
403                        suggestion: Some("Valid events: INSERT, UPDATE, DELETE".to_string()),
404                    });
405                }
406
407                // Validate at least one action exists
408                if observer.actions.is_empty() {
409                    report.errors.push(ValidationError {
410                        message:    format!(
411                            "Observer '{}' must have at least one action",
412                            observer.name
413                        ),
414                        path:       format!("observers[{idx}].actions"),
415                        severity:   ErrorSeverity::Error,
416                        suggestion: Some("Add a webhook, slack, or email action".to_string()),
417                    });
418                }
419
420                // Validate each action
421                for (action_idx, action) in observer.actions.iter().enumerate() {
422                    if let Some(obj) = action.as_object() {
423                        // Check action has a type field
424                        if let Some(action_type) = obj.get("type").and_then(|v| v.as_str()) {
425                            let valid_action_types = ["webhook", "slack", "email"];
426                            if !valid_action_types.contains(&action_type) {
427                                report.errors.push(ValidationError {
428                                    message:    format!(
429                                        "Observer '{}' action {} has invalid type '{}'",
430                                        observer.name, action_idx, action_type
431                                    ),
432                                    path:       format!(
433                                        "observers[{idx}].actions[{action_idx}].type"
434                                    ),
435                                    severity:   ErrorSeverity::Error,
436                                    suggestion: Some(
437                                        "Valid action types: webhook, slack, email".to_string(),
438                                    ),
439                                });
440                            }
441
442                            // Validate action-specific required fields
443                            match action_type {
444                                "webhook" => {
445                                    let has_url = obj.contains_key("url");
446                                    let has_url_env = obj.contains_key("url_env");
447                                    if !has_url && !has_url_env {
448                                        report.errors.push(ValidationError {
449                                            message:    format!(
450                                                "Observer '{}' webhook action must have 'url' or 'url_env'",
451                                                observer.name
452                                            ),
453                                            path:       format!("observers[{idx}].actions[{action_idx}]"),
454                                            severity:   ErrorSeverity::Error,
455                                            suggestion: Some("Add 'url' or 'url_env' field".to_string()),
456                                        });
457                                    }
458                                },
459                                "slack" => {
460                                    if !obj.contains_key("channel") {
461                                        report.errors.push(ValidationError {
462                                            message:    format!(
463                                                "Observer '{}' slack action must have 'channel' field",
464                                                observer.name
465                                            ),
466                                            path:       format!("observers[{idx}].actions[{action_idx}]"),
467                                            severity:   ErrorSeverity::Error,
468                                            suggestion: Some("Add 'channel' field (e.g., '#sales')".to_string()),
469                                        });
470                                    }
471                                    if !obj.contains_key("message") {
472                                        report.errors.push(ValidationError {
473                                            message:    format!(
474                                                "Observer '{}' slack action must have 'message' field",
475                                                observer.name
476                                            ),
477                                            path:       format!("observers[{idx}].actions[{action_idx}]"),
478                                            severity:   ErrorSeverity::Error,
479                                            suggestion: Some("Add 'message' field".to_string()),
480                                        });
481                                    }
482                                },
483                                "email" => {
484                                    let required_fields = ["to", "subject", "body"];
485                                    for field in &required_fields {
486                                        if !obj.contains_key(*field) {
487                                            report.errors.push(ValidationError {
488                                                message:    format!(
489                                                    "Observer '{}' email action must have '{}' field",
490                                                    observer.name, field
491                                                ),
492                                                path:       format!("observers[{idx}].actions[{action_idx}]"),
493                                                severity:   ErrorSeverity::Error,
494                                                suggestion: Some(format!("Add '{field}' field")),
495                                            });
496                                        }
497                                    }
498                                },
499                                _ => {},
500                            }
501                        } else {
502                            report.errors.push(ValidationError {
503                                message:    format!(
504                                    "Observer '{}' action {} missing 'type' field",
505                                    observer.name, action_idx
506                                ),
507                                path:       format!("observers[{idx}].actions[{action_idx}]"),
508                                severity:   ErrorSeverity::Error,
509                                suggestion: Some(
510                                    "Add 'type' field (webhook, slack, or email)".to_string(),
511                                ),
512                            });
513                        }
514                    } else {
515                        report.errors.push(ValidationError {
516                            message:    format!(
517                                "Observer '{}' action {} must be an object",
518                                observer.name, action_idx
519                            ),
520                            path:       format!("observers[{idx}].actions[{action_idx}]"),
521                            severity:   ErrorSeverity::Error,
522                            suggestion: None,
523                        });
524                    }
525                }
526
527                // Validate retry config
528                let valid_backoff_strategies = ["exponential", "linear", "fixed"];
529                if !valid_backoff_strategies.contains(&observer.retry.backoff_strategy.as_str()) {
530                    report.errors.push(ValidationError {
531                        message:    format!(
532                            "Observer '{}' has invalid backoff_strategy '{}'",
533                            observer.name, observer.retry.backoff_strategy
534                        ),
535                        path:       format!("observers[{idx}].retry.backoff_strategy"),
536                        severity:   ErrorSeverity::Error,
537                        suggestion: Some(
538                            "Valid strategies: exponential, linear, fixed".to_string(),
539                        ),
540                    });
541                }
542
543                if observer.retry.max_attempts == 0 {
544                    report.errors.push(ValidationError {
545                        message:    format!(
546                            "Observer '{}' has max_attempts=0, actions will never execute",
547                            observer.name
548                        ),
549                        path:       format!("observers[{idx}].retry.max_attempts"),
550                        severity:   ErrorSeverity::Warning,
551                        suggestion: Some("Set max_attempts >= 1".to_string()),
552                    });
553                }
554
555                if observer.retry.initial_delay_ms == 0 {
556                    report.errors.push(ValidationError {
557                        message:    format!(
558                            "Observer '{}' has initial_delay_ms=0, retries will be immediate",
559                            observer.name
560                        ),
561                        path:       format!("observers[{idx}].retry.initial_delay_ms"),
562                        severity:   ErrorSeverity::Warning,
563                        suggestion: Some("Consider setting initial_delay_ms > 0".to_string()),
564                    });
565                }
566
567                if observer.retry.max_delay_ms < observer.retry.initial_delay_ms {
568                    report.errors.push(ValidationError {
569                        message:    format!(
570                            "Observer '{}' has max_delay_ms < initial_delay_ms",
571                            observer.name
572                        ),
573                        path:       format!("observers[{idx}].retry.max_delay_ms"),
574                        severity:   ErrorSeverity::Error,
575                        suggestion: Some("max_delay_ms must be >= initial_delay_ms".to_string()),
576                    });
577                }
578            }
579        }
580
581        // Validate sources (#573 scheduled ingress — the dual of observers).
582        if let Some(sources) = &schema.sources {
583            let mut source_names = HashSet::new();
584            let mut cursor_names = HashSet::new();
585            for (idx, source) in sources.iter().enumerate() {
586                // Names key the durable cursor and the advisory lease — they must be unique.
587                if !source_names.insert(source.name.clone()) {
588                    report.errors.push(ValidationError {
589                        message:    format!("Duplicate source name: '{}'", source.name),
590                        path:       format!("sources[{idx}].name"),
591                        severity:   ErrorSeverity::Error,
592                        suggestion: Some("Source names must be unique".to_string()),
593                    });
594                }
595
596                // A shared cursor name would let two sources clobber each other's watermark.
597                let cursor = source.cursor_name().to_string();
598                if !cursor_names.insert(cursor.clone()) {
599                    report.errors.push(ValidationError {
600                        message:    format!(
601                            "Source '{}' reuses cursor '{cursor}' already claimed by another source",
602                            source.name
603                        ),
604                        path:       format!("sources[{idx}].cursor"),
605                        severity:   ErrorSeverity::Error,
606                        suggestion: Some(
607                            "Give each source a distinct cursor (it defaults to the source name)"
608                                .to_string(),
609                        ),
610                    });
611                }
612
613                // The schedule must parse as the runtime parses it: the same parser, so
614                // a schedule the compiler approves is one the scheduler can run (#1399).
615                if let Err(error) =
616                    fraiseql_functions::triggers::CronSchedule::parse(&source.schedule)
617                {
618                    report.errors.push(ValidationError {
619                        message:    format!(
620                            "Source '{}' has an invalid cron schedule '{}': {error}",
621                            source.name, source.schedule
622                        ),
623                        path:       format!("sources[{idx}].schedule"),
624                        severity:   ErrorSeverity::Error,
625                        suggestion: Some(
626                            "Use a 5-field cron expression, e.g. '*/5 * * * *'".to_string(),
627                        ),
628                    });
629                }
630
631                // The bound handler must be named.
632                if source.function.trim().is_empty() {
633                    report.errors.push(ValidationError {
634                        message:    format!("Source '{}' has no function", source.name),
635                        path:       format!("sources[{idx}].function"),
636                        severity:   ErrorSeverity::Error,
637                        suggestion: Some("Set the handler function name".to_string()),
638                    });
639                }
640
641                // The `run_as` authority ceiling (#573 D6): a blank grant is a config
642                // error, and an empty ceiling is fail-closed (the source can write
643                // nothing) — valid, but almost always a mistake, so warn.
644                if let Some(run_as) = &source.run_as {
645                    let has_blank = run_as
646                        .roles
647                        .iter()
648                        .chain(run_as.scopes.iter())
649                        .any(|grant| grant.trim().is_empty())
650                        || [&run_as.tenant, &run_as.user_id]
651                            .into_iter()
652                            .any(|v| v.as_ref().is_some_and(|v| v.trim().is_empty()));
653                    if has_blank {
654                        report.errors.push(ValidationError {
655                            message:    format!(
656                                "Source '{}' has a blank role, scope, tenant, or user_id in run_as",
657                                source.name
658                            ),
659                            path:       format!("sources[{idx}].run_as"),
660                            severity:   ErrorSeverity::Error,
661                            suggestion: Some(
662                                "Remove empty entries; each role/scope/tenant/user_id must be non-blank"
663                                    .to_string(),
664                            ),
665                        });
666                    }
667                    if run_as.roles.is_empty() && run_as.scopes.is_empty() {
668                        report.errors.push(ValidationError {
669                            message:    format!(
670                                "Source '{}' declares run_as with no authority (no roles or \
671                                 scopes): its mutations will be denied (fail-closed)",
672                                source.name
673                            ),
674                            path:       format!("sources[{idx}].run_as"),
675                            severity:   ErrorSeverity::Warning,
676                            suggestion: Some(
677                                "Grant the source the least-privilege roles/scopes its mutations \
678                                 need, or drop run_as if it never mutates"
679                                    .to_string(),
680                            ),
681                        });
682                    }
683                }
684            }
685        }
686
687        info!(
688            "Validation complete: {} errors, {} warnings",
689            report.error_count(),
690            report.warning_count()
691        );
692
693        Ok(report)
694    }
695
696    /// Suggest similar type names for a typo.
697    ///
698    /// Delegates ranking to [`fraiseql_core::runtime::suggest_similar`], a real edit-distance
699    /// match over `char`s. What this replaces did two things wrong at once:
700    ///
701    /// * It sliced `&typo[0..1]` and `&name[0..1]` — **byte** ranges — so an empty base type
702    ///   (`"return_type": ""`) or any name beginning with a multi-byte character *panicked the CLI
703    ///   while it was composing an error message*. The panic was documented in a `# Panics` section
704    ///   that no caller honoured (#724 item 1).
705    /// * It matched on first letter only, behind a comment calling itself "Levenshtein-style". For
706    ///   a typo of `User` it offered `Universe` and `Umbrella`.
707    ///
708    /// Deterministic output: `available` is a `HashSet`, so candidates are sorted before
709    /// ranking and the fallback list is sorted too. Without that, the same schema produced
710    /// different error text run to run.
711    fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
712        let mut candidates: Vec<&str> = available.iter().map(String::as_str).collect();
713        candidates.sort_unstable();
714
715        let similar = fraiseql_core::runtime::suggest_similar(typo, &candidates);
716        if similar.is_empty() {
717            format!("Available types: {}", candidates.join(", "))
718        } else {
719            format!("Did you mean: {}?", similar.join(", "))
720        }
721    }
722}