Skip to main content

memstead_schema/
loader.rs

1//! Schema loader — reads on-disk schema directories (or in-memory YAML) into
2//! validated `Schema` values with `edge_weights` resolved.
3//!
4//! Three validation layers coordinate here:
5//! 1. Structural (serde + `deny_unknown_fields`) — handled by the deserialize.
6//! 2. Semantic (this module) — cross-field rules listed in `SchemaLoadError`.
7//! 3. Editor (JSON Schemas) — generated by `emit_json_schemas`, consumed by
8//!    schema authors via `# yaml-language-server: $schema=...`.
9
10use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use indexmap::IndexMap;
15use thiserror::Error;
16
17use crate::base_metadata;
18use crate::manifest::SchemaManifest;
19use crate::schema::Schema;
20use crate::types::TypeDefinition;
21
22#[derive(Debug, Error)]
23pub enum SchemaLoadError {
24    #[error("i/o error reading {}: {source}", .path.display())]
25    Io {
26        path: PathBuf,
27        #[source]
28        source: std::io::Error,
29    },
30
31    #[error("failed to parse manifest {}: {source}", .path.display())]
32    ParseManifest {
33        path: PathBuf,
34        #[source]
35        source: serde_yaml_ng::Error,
36    },
37
38    #[error("failed to parse type file {}: {source}", .path.display())]
39    ParseType {
40        path: PathBuf,
41        #[source]
42        source: serde_yaml_ng::Error,
43    },
44
45    #[error("invalid version '{value}': must be semver (e.g. 1.0.0)")]
46    InvalidVersion { value: String },
47
48    #[error("invalid schema name '{value}': {reason}")]
49    InvalidName { value: String, reason: &'static str },
50
51    #[error(
52        "schema type file mismatch — declared in manifest: [{}], found in types/: [{}]",
53        declared.join(", "),
54        found.join(", ")
55    )]
56    TypeFileMismatch {
57        declared: Vec<String>,
58        found: Vec<String>,
59    },
60
61    #[error(
62        "type file '{file}.yaml' has `name: {declared}` — filename and `name` field must match"
63    )]
64    TypeNameMismatch { file: String, declared: String },
65
66    #[error("schema relationship vocabulary must include a '_default' definition")]
67    MissingDefaultWeight,
68
69    #[error("duplicate relationship definition: '{name}'")]
70    DuplicateRelationship { name: String },
71
72    #[error(
73        "type '{type_name}' references relationship '{relationship}' in field '{field}' — not declared in schema. Available: [{}]. {}",
74        available.join(", "),
75        format_suggestion(relationship, available)
76    )]
77    UndeclaredRelationship {
78        type_name: String,
79        field: &'static str,
80        relationship: String,
81        available: Vec<String>,
82    },
83
84    #[error(
85        "type '{type_name}' must have exactly one section with `catch_all: true` (found {count})"
86    )]
87    CatchAllViolation { type_name: String, count: usize },
88
89    #[error(
90        "type '{type_name}' field '{field}' references unknown key '{reference}' — not a section or metadata field"
91    )]
92    UnknownFieldReference {
93        type_name: String,
94        field: &'static str,
95        reference: String,
96    },
97
98    #[error(
99        "type '{type_name}' metadata field '{field}' default '{default}' is not listed in enum_values: [{}]",
100        allowed.join(", ")
101    )]
102    DefaultValueNotInEnum {
103        type_name: String,
104        field: String,
105        default: String,
106        allowed: Vec<String>,
107    },
108
109    #[error(
110        "type '{type_name}' redeclares engine-implicit metadata key '{field}' — remove it from the YAML; the loader injects it automatically"
111    )]
112    RedeclaredBaseField { type_name: String, field: String },
113
114    #[error(
115        "relationship '{relationship}' field '{field}' references unknown type '{reference}'. Declared types: [{}]. {}",
116        declared.join(", "),
117        format_suggestion(reference, declared)
118    )]
119    UndeclaredRelationshipType {
120        relationship: String,
121        field: &'static str,
122        reference: String,
123        declared: Vec<String>,
124    },
125
126    /// Schema declares a regular section or metadata field whose key
127    /// collides with an engine-invariant key. The reserved set covers
128    /// section key `relationships` (the parser's auto-managed
129    /// `## Relationships` section) and metadata field key `type` (the
130    /// engine-set frontmatter type discriminator).
131    #[error(
132        "type '{type_name}' declares {kind} with reserved key '{offending_key}' — reserved keys: [{}]",
133        reserved_keys.join(", ")
134    )]
135    ReservedSchemaKey {
136        type_name: String,
137        kind: &'static str,
138        offending_key: String,
139        reserved_keys: Vec<String>,
140    },
141
142    /// A `cross_mem_relationships:` entry's `to_schema:` field is
143    /// not a bare schema name. Cross-mem eligibility is name-based —
144    /// versioned (`software@1.0.0`) and range (`software@^1.0`) forms
145    /// are refused so a version component can never silently re-enter
146    /// the eligibility path.
147    #[error(
148        "cross_mem_relationships[].to_schema '{value}' {reason} — expected a bare schema name (e.g. 'software', not 'software@1.0.0')"
149    )]
150    InvalidCrossMemToSchema { value: String, reason: String },
151
152    /// Two `cross_mem_relationships:` entries declare the same
153    /// `to_schema:`. A schema declares each target-schema at most once
154    /// — the second entry would otherwise silently shadow or split
155    /// the vocabulary.
156    #[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
157    DuplicateCrossMemToSchema { to_schema: String },
158
159    /// A `cross_mem_relationships[].definitions[*].source_types` entry
160    /// references a type name not declared in the source schema's
161    /// `types` list. Source types belong to the source schema's
162    /// namespace; unknown names raise this error at load time.
163    /// (Target types are accepted as opaque strings — they belong to
164    /// the target schema's namespace, which is not in scope here.)
165    #[error(
166        "cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
167        declared.join(", "),
168        format_suggestion(reference, declared)
169    )]
170    UndeclaredCrossMemSourceType {
171        to_schema: String,
172        relationship: String,
173        reference: String,
174        declared: Vec<String>,
175    },
176
177    /// The schema's `alias_target_rel_type:` pointer names a rel-type
178    /// not declared in `relationships.definitions`. Surfaces at
179    /// schema-load time so the alias-synthesis pass can trust the
180    /// pointer is resolvable at every later mutation call.
181    #[error(
182        "schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
183        declared.join(", "),
184        format_suggestion(target, declared)
185    )]
186    AliasTargetRelTypeNotDeclared {
187        schema: String,
188        target: String,
189        declared: Vec<String>,
190    },
191}
192
193/// Engine-invariant section keys reserved against schema use. The
194/// parser's auto-managed `## Relationships` section is the only entry
195/// today.
196pub fn reserved_section_keys() -> &'static [&'static str] {
197    &["relationships"]
198}
199
200/// Engine-invariant metadata-field keys reserved against schema use.
201/// `type` is the engine-set frontmatter discriminator.
202pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
203    &["type"]
204}
205
206fn format_suggestion(needle: &str, candidates: &[String]) -> String {
207    let mut best: Option<(usize, &String)> = None;
208    for cand in candidates {
209        let d = strsim::levenshtein(needle, cand);
210        match best {
211            Some((bd, _)) if bd <= d => {}
212            _ => best = Some((d, cand)),
213        }
214    }
215    match best {
216        Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
217            format!("Did you mean '{cand}'?")
218        }
219        _ => String::new(),
220    }
221}
222
223/// Load a schema from a directory containing `schema.yaml` and `types/*.yaml`.
224pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
225    let manifest_path = path.join("schema.yaml");
226    let manifest_text =
227        std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
228            path: manifest_path.clone(),
229            source: e,
230        })?;
231
232    let types_dir = path.join("types");
233    let mut type_files: Vec<(String, String)> = Vec::new();
234    if types_dir.is_dir() {
235        let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
236            path: types_dir.clone(),
237            source: e,
238        })?;
239        for entry in entries {
240            let entry = entry.map_err(|e| SchemaLoadError::Io {
241                path: types_dir.clone(),
242                source: e,
243            })?;
244            let p = entry.path();
245            if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
246                continue;
247            }
248            let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
249                continue;
250            };
251            let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
252                path: p.clone(),
253                source: e,
254            })?;
255            type_files.push((stem, contents));
256        }
257    }
258
259    load_with_context(
260        &manifest_text,
261        &type_files,
262        Some(&manifest_path),
263        Some(&types_dir),
264    )
265}
266
267/// Load a schema from in-memory YAML strings.
268///
269/// `types_yamls` is a slice of `(filename_stem, contents)` tuples — the stems
270/// must match `manifest.types` exactly.
271pub fn load_schema_from_memory(
272    manifest_yaml: &str,
273    types_yamls: &[(String, String)],
274) -> Result<Schema, SchemaLoadError> {
275    load_with_context(manifest_yaml, types_yamls, None, None)
276}
277
278fn load_with_context(
279    manifest_yaml: &str,
280    types_yamls: &[(String, String)],
281    manifest_path: Option<&Path>,
282    types_dir: Option<&Path>,
283) -> Result<Schema, SchemaLoadError> {
284    let mut manifest: SchemaManifest =
285        serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
286            path: manifest_path
287                .map(Path::to_path_buf)
288                .unwrap_or_else(|| PathBuf::from("<memory>")),
289            source: e,
290        })?;
291
292    validate_name(&manifest.name)?;
293
294    let version =
295        semver::Version::parse(&manifest.version).map_err(|_| SchemaLoadError::InvalidVersion {
296            value: manifest.version.clone(),
297        })?;
298
299    // Relationship vocabulary: unique names + _default present
300    let mut rel_names: HashSet<String> = HashSet::new();
301    for def in &manifest.relationships.definitions {
302        if !rel_names.insert(def.name.clone()) {
303            return Err(SchemaLoadError::DuplicateRelationship {
304                name: def.name.clone(),
305            });
306        }
307    }
308    if !rel_names.contains("_default") {
309        return Err(SchemaLoadError::MissingDefaultWeight);
310    }
311    let available_rels: Vec<String> = manifest
312        .relationships
313        .definitions
314        .iter()
315        .map(|d| d.name.clone())
316        .collect();
317
318    // Validate that the schema-level alias_target_rel_type pointer (if
319    // set) names a declared rel-type. The synthesis pass later relies
320    // on this invariant — running it as a load-time check keeps the
321    // mutation path's hot loop free of resolution failures.
322    if let Some(target) = &manifest.alias_target_rel_type
323        && !rel_names.contains(target)
324    {
325        let mut declared = available_rels.clone();
326        declared.sort();
327        return Err(SchemaLoadError::AliasTargetRelTypeNotDeclared {
328            schema: manifest.name.clone(),
329            target: target.clone(),
330            declared,
331        });
332    }
333
334    // Option C coupling — auto-force `manual_authoring: forbidden` on
335    // the rel-type named by `alias_target_rel_type`. Schemas setting
336    // the pointer opt the named rel-type out of explicit authoring;
337    // the only path to a relation of that rel-type is via the
338    // alias-synthesis pass that emits one per body wiki-link. This
339    // closes the explicit/synthesised coexistence question: with the
340    // coupling in place, edges of the pointer rel-type are always
341    // engine-emitted, so `EdgeSource::BodyLink` is unambiguous and
342    // GC can drop pointer-rel-type relations without risking
343    // explicit-author data.
344    //
345    // The coupling is silent — a schema that writes
346    // `manual_authoring: allow` (or `warn`) on the named rel-type
347    // gets overridden to `forbidden` at load. The override is the
348    // schema-strictness contract; explicit `allow`/`warn` on the
349    // pointer rel-type is meaningless under the design and would
350    // surprise the validator at runtime, so the loader corrects it
351    // here.
352    if let Some(pointer) = manifest.alias_target_rel_type.clone() {
353        for def in &mut manifest.relationships.definitions {
354            if def.name == pointer {
355                def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
356            }
357        }
358    }
359
360    // Cross-check `source_types` / `target_types` on each relationship
361    // definition against the manifest's declared type list. Unknown
362    // names raise `UndeclaredRelationshipType` with a "did you mean"
363    // suggestion — the schema-author equivalent of `INVALID_REL_SHAPE`.
364    for def in &manifest.relationships.definitions {
365        for t in &def.source_types {
366            if !manifest.types.iter().any(|d| d == t) {
367                return Err(SchemaLoadError::UndeclaredRelationshipType {
368                    relationship: def.name.clone(),
369                    field: "source_types",
370                    reference: t.clone(),
371                    declared: manifest.types.clone(),
372                });
373            }
374        }
375        for t in &def.target_types {
376            if !manifest.types.iter().any(|d| d == t) {
377                return Err(SchemaLoadError::UndeclaredRelationshipType {
378                    relationship: def.name.clone(),
379                    field: "target_types",
380                    reference: t.clone(),
381                    declared: manifest.types.clone(),
382                });
383            }
384        }
385    }
386
387    // Cross-mem relationships: validate `to_schema` is a bare schema
388    // name (cross-mem eligibility is name-based — a version suffix or
389    // range refuses), refuse duplicate target schemas, and cross-check
390    // `source_types` against the source schema's types. `target_types`
391    // are accepted as opaque strings — they belong to the target
392    // schema's namespace, which is out of scope at source-schema load
393    // time. The target schema may not even be present in the workspace
394    // when the source schema loads (and cross-mem declarations
395    // targeting absent schemas are legitimate for portable library
396    // schemas).
397    let mut seen_to_schemas: HashSet<String> = HashSet::new();
398    for entry in &manifest.cross_mem_relationships {
399        if entry.to_schema.contains('@') {
400            return Err(SchemaLoadError::InvalidCrossMemToSchema {
401                value: entry.to_schema.clone(),
402                reason: "must not carry a version or range".into(),
403            });
404        }
405        if let Err(reason) = name_shape(&entry.to_schema) {
406            return Err(SchemaLoadError::InvalidCrossMemToSchema {
407                value: entry.to_schema.clone(),
408                reason: reason.into(),
409            });
410        }
411        if !seen_to_schemas.insert(entry.to_schema.clone()) {
412            return Err(SchemaLoadError::DuplicateCrossMemToSchema {
413                to_schema: entry.to_schema.clone(),
414            });
415        }
416        for def in &entry.definitions {
417            for t in &def.source_types {
418                if !manifest.types.iter().any(|d| d == t) {
419                    return Err(SchemaLoadError::UndeclaredCrossMemSourceType {
420                        to_schema: entry.to_schema.clone(),
421                        relationship: def.name.clone(),
422                        reference: t.clone(),
423                        declared: manifest.types.clone(),
424                    });
425                }
426            }
427        }
428    }
429
430    // Type file cross-check: declared vs found, set equality (order-insensitive)
431    let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
432    found_stems.sort();
433    let mut declared = manifest.types.clone();
434    declared.sort();
435    if found_stems != declared {
436        return Err(SchemaLoadError::TypeFileMismatch {
437            declared,
438            found: found_stems,
439        });
440    }
441
442    // Per-type defaults map for edge_weights resolution
443    let defaults: IndexMap<String, f32> = manifest
444        .relationships
445        .definitions
446        .iter()
447        .map(|d| (d.name.clone(), d.default_weight))
448        .collect();
449
450    let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
451
452    for (stem, text) in types_yamls {
453        let type_path = types_dir
454            .map(|d| d.join(format!("{stem}.yaml")))
455            .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
456
457        let mut td: TypeDefinition =
458            serde_yaml_ng::from_str(text).map_err(|e| SchemaLoadError::ParseType {
459                path: type_path.clone(),
460                source: e,
461            })?;
462
463        if td.name != *stem {
464            return Err(SchemaLoadError::TypeNameMismatch {
465                file: stem.clone(),
466                declared: td.name.clone(),
467            });
468        }
469
470        // Reserved-metadata-field-key check. `type` is the engine-set
471        // frontmatter discriminator and must never appear in a schema's
472        // metadata field list. Runs before the base-metadata merge so
473        // the engine-injected fields don't false-positive here.
474        for field in &td.metadata_fields {
475            if reserved_metadata_field_keys().contains(&field.key.as_str()) {
476                return Err(SchemaLoadError::ReservedSchemaKey {
477                    type_name: td.name.clone(),
478                    kind: "metadata_field",
479                    offending_key: field.key.clone(),
480                    reserved_keys: reserved_metadata_field_keys()
481                        .iter()
482                        .map(|s| s.to_string())
483                        .collect(),
484                });
485            }
486        }
487
488        // Reject redeclarations of remaining engine-implicit base metadata
489        // (`created_date`, `last_modified`, `tags`). The reserved `type`
490        // case was already handled above with the typed reserved-key
491        // error.
492        for field in &td.metadata_fields {
493            if base_metadata::is_base_key(&field.key)
494                && !reserved_metadata_field_keys().contains(&field.key.as_str())
495            {
496                return Err(SchemaLoadError::RedeclaredBaseField {
497                    type_name: td.name.clone(),
498                    field: field.key.clone(),
499                });
500            }
501        }
502
503        // Merge base metadata around the type-declared fields. Canonical
504        // order: type, created_date, last_modified, <declared>, tags.
505        let mut merged = base_metadata::prefix_fields();
506        merged.append(&mut td.metadata_fields);
507        merged.extend(base_metadata::suffix_fields());
508        td.metadata_fields = merged;
509
510        validate_type(&td, &rel_names, &available_rels)?;
511
512        // Resolve edge_weights: start with schema defaults, apply overrides.
513        let mut weights = defaults.clone();
514        for (k, v) in &td.edge_weight_overrides {
515            weights.insert(k.clone(), *v);
516        }
517        td.edge_weights = weights;
518
519        types_map.insert(stem.clone(), Arc::new(td));
520    }
521
522    Ok(Schema {
523        manifest,
524        version,
525        types: types_map,
526    })
527}
528
529fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
530    name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
531        value: name.into(),
532        reason,
533    })
534}
535
536/// Author-time access to the schema-name shape rule — the same check
537/// the loader runs on a manifest's `name:`. Exposed so scaffolding
538/// tooling (`memstead schema new`) can refuse a bad name up front with
539/// the loader's own reason string instead of a drifting copy of the
540/// grammar.
541pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
542    name_shape(name)
543}
544
545/// Shared shape rule for schema names — the manifest's own `name:` and
546/// every `cross_mem_relationships[].to_schema` follow the same
547/// grammar; the two callers wrap violations in their field-specific
548/// error variants.
549fn name_shape(name: &str) -> Result<(), &'static str> {
550    if name.is_empty() {
551        return Err("must not be empty");
552    }
553    let mut chars = name.chars();
554    let first = chars.next().unwrap();
555    if !first.is_ascii_lowercase() {
556        return Err("must start with a lowercase letter");
557    }
558    for c in chars {
559        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
560            return Err("must contain only lowercase letters, digits, and hyphens");
561        }
562    }
563    Ok(())
564}
565
566fn validate_type(
567    td: &TypeDefinition,
568    rel_names: &HashSet<String>,
569    available_rels: &[String],
570) -> Result<(), SchemaLoadError> {
571    // Reserved-section check. Section key `relationships` collides
572    // with the parser's auto-managed `## Relationships` section.
573    // Domain conventions (`identity`, `purpose`, ...) are NOT reserved.
574    // The reserved-metadata-key check runs earlier in
575    // `load_with_context` against the *raw* author-declared field list
576    // so the base-metadata merge doesn't false-positive against
577    // engine-injected keys.
578    for section in &td.sections {
579        if reserved_section_keys().contains(&section.key.as_str()) {
580            return Err(SchemaLoadError::ReservedSchemaKey {
581                type_name: td.name.clone(),
582                kind: "section",
583                offending_key: section.key.clone(),
584                reserved_keys: reserved_section_keys()
585                    .iter()
586                    .map(|s| s.to_string())
587                    .collect(),
588            });
589        }
590    }
591
592    check_rel(
593        &td.name,
594        "hierarchy_relationship",
595        &td.hierarchy_relationship,
596        rel_names,
597        available_rels,
598    )?;
599    for r in &td.propagating_relationships {
600        check_rel(
601            &td.name,
602            "propagating_relationships",
603            r,
604            rel_names,
605            available_rels,
606        )?;
607    }
608    for r in td.edge_weight_overrides.keys() {
609        check_rel(
610            &td.name,
611            "edge_weight_overrides",
612            r,
613            rel_names,
614            available_rels,
615        )?;
616    }
617    for block in &td.required_outgoing {
618        for r in &block.relationships {
619            check_rel(&td.name, "required_outgoing", r, rel_names, available_rels)?;
620        }
621    }
622
623    // Exactly one catch_all section
624    let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
625    if catch_all_count != 1 {
626        return Err(SchemaLoadError::CatchAllViolation {
627            type_name: td.name.clone(),
628            count: catch_all_count,
629        });
630    }
631
632    // Field-reference integrity
633    let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
634    let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
635
636    for f in &td.text_fields {
637        // text_fields point at section content — not metadata.
638        if !section_keys.contains(f.as_str()) {
639            return Err(SchemaLoadError::UnknownFieldReference {
640                type_name: td.name.clone(),
641                field: "text_fields",
642                reference: f.clone(),
643            });
644        }
645    }
646    for f in &td.health_required_fields {
647        if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
648            return Err(SchemaLoadError::UnknownFieldReference {
649                type_name: td.name.clone(),
650                field: "health_required_fields",
651                reference: f.clone(),
652            });
653        }
654    }
655    for f in &td.updatable_fields {
656        // `title` is the entity's filename-derived title — always updatable.
657        if f == "title" {
658            continue;
659        }
660        if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
661            return Err(SchemaLoadError::UnknownFieldReference {
662                type_name: td.name.clone(),
663                field: "updatable_fields",
664                reference: f.clone(),
665            });
666        }
667    }
668
669    // Metadata default_value must be a member of enum_values when both present.
670    for m in &td.metadata_fields {
671        if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
672            && !allowed.contains(default)
673        {
674            return Err(SchemaLoadError::DefaultValueNotInEnum {
675                type_name: td.name.clone(),
676                field: m.key.clone(),
677                default: default.clone(),
678                allowed: allowed.clone(),
679            });
680        }
681    }
682
683    Ok(())
684}
685
686fn check_rel(
687    type_name: &str,
688    field: &'static str,
689    relationship: &str,
690    rel_names: &HashSet<String>,
691    available: &[String],
692) -> Result<(), SchemaLoadError> {
693    if rel_names.contains(relationship) {
694        return Ok(());
695    }
696    Err(SchemaLoadError::UndeclaredRelationship {
697        type_name: type_name.into(),
698        field,
699        relationship: relationship.into(),
700        available: available.to_vec(),
701    })
702}