Skip to main content

kyyn_core/
registry.rs

1//! The registry vocabulary — the *courtesy* artifact a KB's schema crate emits
2//! (RON-native, committed to the KB) describing its kinds so the engine can
3//! drive query/web/MCP UX generically. Integrity never depends on it: the
4//! validation fn is the only load-bearing export. Where a protocol demands
5//! JSON (MCP tool inputs), the engine derives it from this at the boundary.
6
7use serde::{Deserialize, Serialize};
8
9use crate::query::{
10    BindingSource, BoolExpr, EdgeDirection, FieldRef, QueryDecl, ScalarExpr, TypedLiteral,
11};
12
13/// Everything the engine may know about a KB's shapes.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Registry {
16    /// Hash of the schema crate's source — accept checks freshness against it.
17    pub schema_hash: String,
18    pub kinds: Vec<Kind>,
19    /// Schema-authored analytical queries. The schema constructs these through
20    /// the typed builder; this committed representation is backend-neutral and
21    /// can be rendered by the web UI without parsing Rust.
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub queries: Vec<QueryDecl>,
24    /// This KB's role vocabulary — flat, declared once, adoptable by any
25    /// kind (a Field's `role` names one). Universal for THIS KB, invisible
26    /// to every other: the KB's words, mapped onto engine affordances.
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub roles: Vec<RoleDecl>,
29}
30
31impl Registry {
32    pub fn role(&self, name: &str) -> Option<&RoleDecl> {
33        self.roles.iter().find(|r| r.name == name)
34    }
35
36    /// The engine affordance a field's adopted role binds, if any.
37    pub fn binds(&self, field: &Field) -> Option<Affordance> {
38        field
39            .role
40            .as_deref()
41            .and_then(|n| self.role(n))
42            .map(|r| r.binds)
43    }
44
45    /// The field of `kind` bound to `affordance`, if any.
46    pub fn affordance_field<'a>(&self, kind: &'a Kind, a: Affordance) -> Option<&'a Field> {
47        kind.fields.iter().find(|f| self.binds(f) == Some(a))
48    }
49
50    /// STRICT role coherence — an incoherent mapping makes the registry
51    /// meaningless, so these are hard errors wherever a registry is loaded
52    /// or generated: every adopted role is declared; the field's shape fits
53    /// the affordance (Title = str, Timeline = date, Badge = enum); a Badge
54    /// role's shared variants match the adopting field's enum EXACTLY. A
55    /// kind wanting different vocabulary declares its own enum field and
56    /// simply doesn't adopt the role.
57    pub fn coherence_errors(&self) -> Vec<String> {
58        // Affordances are SCALAR record-level statements: Option peels (an
59        // unset title is still the title field) but List must not — a
60        // List<Str> is not a title, and every consumer reads one scalar.
61        fn base(ty: &FieldType) -> &FieldType {
62            match ty {
63                FieldType::Option(i) => base(i),
64                other => other,
65            }
66        }
67        let mut errors = Vec::new();
68        for (i, r) in self.roles.iter().enumerate() {
69            if self.roles[..i].iter().any(|x| x.name == r.name) {
70                errors.push(format!("role '{}' is declared twice", r.name));
71            }
72            if r.binds == Affordance::Badge && r.variants.is_empty() {
73                errors.push(format!("badge role '{}' declares no variants", r.name));
74            }
75            if r.binds != Affordance::Badge && !r.variants.is_empty() {
76                errors.push(format!(
77                    "role '{}' binds {:?} but declares variants — only Badge roles carry them",
78                    r.name, r.binds
79                ));
80            }
81        }
82        // Roles bind at RECORD level: only a kind's top-level fields may
83        // adopt one. A nested adoption would be silently inert (no consumer
84        // looks below the top level) — refuse it loudly instead.
85        fn nested_roles(prefix: &str, ty: &FieldType, errors: &mut Vec<String>) {
86            match ty {
87                FieldType::Option(i) | FieldType::List(i) => nested_roles(prefix, i, errors),
88                FieldType::Struct(fs) => {
89                    for f in fs {
90                        let at = format!("{prefix}.{}", f.name);
91                        if let Some(role) = &f.role {
92                            errors.push(format!(
93                                "{at} adopts role '{role}' but roles bind only to a \
94                                 kind's top-level fields — nested adoptions are inert"
95                            ));
96                        }
97                        nested_roles(&at, &f.ty, errors);
98                    }
99                }
100                FieldType::Enum(vs) => {
101                    for v in vs {
102                        for f in &v.fields {
103                            let at = format!("{prefix}.{}.{}", v.name, f.name);
104                            if let Some(role) = &f.role {
105                                errors.push(format!(
106                                    "{at} adopts role '{role}' but roles bind only to a \
107                                     kind's top-level fields — nested adoptions are inert"
108                                ));
109                            }
110                            nested_roles(&at, &f.ty, errors);
111                        }
112                    }
113                }
114                _ => {}
115            }
116        }
117        for kind in &self.kinds {
118            for f in &kind.fields {
119                nested_roles(&format!("{}.{}", kind.name, f.name), &f.ty, &mut errors);
120            }
121        }
122        for kind in &self.kinds {
123            // Storage is confined to facts/: `receipts/` (engine-owned),
124            // registry.ron, sources.ron and schema/ are not a kind's to
125            // claim, and `..`/absolute prefixes would escape the ontology.
126            if !kind.storage.starts_with("facts/") || kind.storage.contains("..") {
127                errors.push(format!(
128                    "kind '{}' storage '{}' must live under facts/ (no .., no absolute paths)",
129                    kind.name, kind.storage
130                ));
131            }
132            for f in &kind.fields {
133                let Some(role_name) = f.role.as_deref() else {
134                    continue;
135                };
136                let at = format!("{}.{}", kind.name, f.name);
137                let Some(decl) = self.role(role_name) else {
138                    errors.push(format!("{at} adopts undeclared role '{role_name}'"));
139                    continue;
140                };
141                match (decl.binds, base(&f.ty)) {
142                    (Affordance::Title, FieldType::Str) => {}
143                    (Affordance::Timeline, FieldType::Date) => {}
144                    (Affordance::Badge, FieldType::Enum(vs)) => {
145                        let field_vs: std::collections::BTreeSet<&str> =
146                            vs.iter().map(|v| v.name.as_str()).collect();
147                        let role_vs: std::collections::BTreeSet<&str> =
148                            decl.variants.iter().map(|v| v.name.as_str()).collect();
149                        if field_vs != role_vs {
150                            errors.push(format!(
151                                "{at} adopts badge role '{role_name}' but its variants \
152                                 [{}] differ from the role's [{}] — roles are strict; \
153                                 use the shared enum or drop the role",
154                                vs.iter()
155                                    .map(|v| v.name.as_str())
156                                    .collect::<Vec<_>>()
157                                    .join(", "),
158                                decl.variants
159                                    .iter()
160                                    .map(|v| v.name.as_str())
161                                    .collect::<Vec<_>>()
162                                    .join(", "),
163                            ));
164                        }
165                    }
166                    (binds, other) => errors.push(format!(
167                        "{at} adopts role '{role_name}' (binds {binds:?}) but is {other:?}"
168                    )),
169                }
170            }
171        }
172        errors.extend(self.query_errors());
173        errors
174    }
175
176    /// Bind every emitted query back against this registry. Typed Rust catches
177    /// mistakes at the authoring call site; this second gate protects the
178    /// serialized artifact from drift or hand edits.
179    pub fn query_errors(&self) -> Vec<String> {
180        use std::collections::{BTreeMap, BTreeSet};
181
182        fn base(ty: &FieldType) -> &FieldType {
183            match ty {
184                FieldType::Option(inner) | FieldType::List(inner) => base(inner),
185                other => other,
186            }
187        }
188
189        fn literal_fits(ty: &FieldType, lit: &TypedLiteral) -> bool {
190            match (base(ty), lit) {
191                (FieldType::Str | FieldType::Markdown, TypedLiteral::String(_))
192                | (FieldType::Int, TypedLiteral::Int(_))
193                | (FieldType::Decimal, TypedLiteral::Decimal(_))
194                | (FieldType::Date, TypedLiteral::Date(_))
195                | (FieldType::Bool, TypedLiteral::Bool(_)) => true,
196                (FieldType::Enum(variants), TypedLiteral::Enum { variant, .. }) => {
197                    variants.iter().any(|v| v.name == *variant)
198                }
199                _ => false,
200            }
201        }
202
203        fn field_type<'a>(
204            bindings: &BTreeMap<u32, &'a Kind>,
205            field: &FieldRef,
206        ) -> Option<&'a FieldType> {
207            bindings
208                .get(&field.binding)?
209                .fields
210                .iter()
211                .find(|f| f.name == field.field)
212                .map(|f| &f.ty)
213        }
214
215        fn check_scalar(
216            bindings: &BTreeMap<u32, &Kind>,
217            query: &str,
218            expr: &ScalarExpr,
219            errors: &mut Vec<String>,
220        ) {
221            match expr {
222                ScalarExpr::Field(f) => {
223                    if field_type(bindings, f).is_none() {
224                        errors.push(format!(
225                            "query '{query}' references missing field binding {}.{}",
226                            f.binding, f.field
227                        ));
228                    }
229                }
230                ScalarExpr::Literal(_) => {}
231                ScalarExpr::Aggregate { expr, .. } => {
232                    if let Some(expr) = expr {
233                        check_scalar(bindings, query, expr, errors);
234                    }
235                }
236            }
237        }
238
239        fn check_bool(
240            bindings: &BTreeMap<u32, &Kind>,
241            query: &str,
242            expr: &BoolExpr,
243            errors: &mut Vec<String>,
244        ) {
245            match expr {
246                BoolExpr::And(xs) | BoolExpr::Or(xs) => xs
247                    .iter()
248                    .for_each(|x| check_bool(bindings, query, x, errors)),
249                BoolExpr::Not(x) => check_bool(bindings, query, x, errors),
250                BoolExpr::Compare { left, right, .. } => {
251                    check_scalar(bindings, query, left, errors);
252                    check_scalar(bindings, query, right, errors);
253                    let pair = match (left, right) {
254                        (ScalarExpr::Field(f), ScalarExpr::Literal(l))
255                        | (ScalarExpr::Literal(l), ScalarExpr::Field(f)) => Some((f, l)),
256                        _ => None,
257                    };
258                    if let Some((field, literal)) = pair
259                        && let Some(ty) = field_type(bindings, field)
260                        && !literal_fits(ty, literal)
261                    {
262                        errors.push(format!(
263                            "query '{query}' compares {}.{} ({ty:?}) with incompatible {literal:?}",
264                            field.binding, field.field
265                        ));
266                    }
267                }
268            }
269        }
270
271        let mut errors = Vec::new();
272        let mut names = BTreeSet::new();
273        for query in &self.queries {
274            if !names.insert(query.name.as_str()) {
275                errors.push(format!("query '{}' is declared twice", query.name));
276            }
277            let mut bindings = BTreeMap::new();
278            for binding in &query.bindings {
279                let Some(kind) = self.kinds.iter().find(|k| k.name == binding.kind) else {
280                    errors.push(format!(
281                        "query '{}' binding {} names missing kind '{}'",
282                        query.name, binding.id, binding.kind
283                    ));
284                    continue;
285                };
286                if bindings.insert(binding.id, kind).is_some() {
287                    errors.push(format!(
288                        "query '{}' declares binding {} twice",
289                        query.name, binding.id
290                    ));
291                }
292            }
293            for binding in &query.bindings {
294                let BindingSource::Follow {
295                    from,
296                    field,
297                    direction,
298                    ..
299                } = &binding.source
300                else {
301                    continue;
302                };
303                let (field_kind, target_kind) = match direction {
304                    EdgeDirection::Out => (bindings.get(from), bindings.get(&binding.id)),
305                    EdgeDirection::In => (bindings.get(&binding.id), bindings.get(from)),
306                };
307                let (Some(field_kind), Some(target_kind)) = (field_kind, target_kind) else {
308                    errors.push(format!(
309                        "query '{}' binding {} follows missing binding {}",
310                        query.name, binding.id, from
311                    ));
312                    continue;
313                };
314                let Some(decl) = field_kind.fields.iter().find(|f| f.name == *field) else {
315                    errors.push(format!(
316                        "query '{}' follows missing edge '{}.{}'",
317                        query.name, field_kind.name, field
318                    ));
319                    continue;
320                };
321                let points_to = match base(&decl.ty) {
322                    FieldType::Link { allowed } => allowed
323                        .as_ref()
324                        .is_none_or(|ks| ks.iter().any(|k| k == &target_kind.name)),
325                    FieldType::Str => decl.refers_to.as_deref() == Some(target_kind.name.as_str()),
326                    _ => false,
327                };
328                if !points_to {
329                    errors.push(format!(
330                        "query '{}' edge '{}.{}' does not point to '{}'",
331                        query.name, field_kind.name, field, target_kind.name
332                    ));
333                }
334            }
335            if query.bindings.is_empty() {
336                errors.push(format!("query '{}' has no root binding", query.name));
337            }
338            if query.columns.is_empty() {
339                errors.push(format!("query '{}' has no output columns", query.name));
340            }
341            if let Some(filter) = &query.filter {
342                check_bool(&bindings, &query.name, filter, &mut errors);
343            }
344            for expr in &query.group_by {
345                check_scalar(&bindings, &query.name, expr, &mut errors);
346            }
347            let mut columns = BTreeSet::new();
348            for column in &query.columns {
349                if let Some(f) = &column.format
350                    && (f.thousands || f.decimals.is_some())
351                {
352                    // Numeric display options need a numeric expression: an
353                    // aggregate count/sum, or a numeric field.
354                    let numeric = match &column.expr {
355                        ScalarExpr::Aggregate { .. } => true,
356                        ScalarExpr::Field(fr) => field_type(&bindings, fr)
357                            .map(|ty| matches!(base(ty), FieldType::Int | FieldType::Decimal))
358                            .unwrap_or(false),
359                        ScalarExpr::Literal(l) => {
360                            matches!(l, TypedLiteral::Int(_) | TypedLiteral::Decimal(_))
361                        }
362                    };
363                    if !numeric {
364                        errors.push(format!(
365                            "query '{}' column '{}' uses numeric formatting \
366                             (thousands/decimals) on a non-numeric expression",
367                            query.name, column.name
368                        ));
369                    }
370                }
371                if !columns.insert(column.name.as_str()) {
372                    errors.push(format!(
373                        "query '{}' has duplicate output column '{}'",
374                        query.name, column.name
375                    ));
376                }
377                check_scalar(&bindings, &query.name, &column.expr, &mut errors);
378                // SQL's classic gate, enforced at accept instead of at run
379                // time: under GROUP BY, every non-aggregate output column
380                // must BE one of the grouping expressions.
381                if !query.group_by.is_empty()
382                    && !matches!(column.expr, ScalarExpr::Aggregate { .. })
383                    && !query.group_by.contains(&column.expr)
384                {
385                    errors.push(format!(
386                        "query '{}' output column '{}' is neither aggregated \
387                         nor in group_by",
388                        query.name, column.name
389                    ));
390                }
391            }
392            if query.group_by.is_empty() {
393                let aggs = query
394                    .columns
395                    .iter()
396                    .filter(|c| matches!(c.expr, ScalarExpr::Aggregate { .. }))
397                    .count();
398                if aggs > 0 && aggs < query.columns.len() {
399                    errors.push(format!(
400                        "query '{}' mixes aggregate and plain columns without \
401                         group_by",
402                        query.name
403                    ));
404                }
405            }
406            for expr in &query.group_by {
407                if matches!(expr, ScalarExpr::Aggregate { .. }) {
408                    errors.push(format!(
409                        "query '{}' groups by an aggregate — group_by takes \
410                         field expressions",
411                        query.name
412                    ));
413                }
414            }
415            for order in &query.order_by {
416                check_scalar(&bindings, &query.name, &order.expr, &mut errors);
417            }
418        }
419        errors
420    }
421}
422
423/// One KB-level role: this KB's own vocabulary, declared once and adoptable
424/// by any kind. STRICT: a Badge role carries the shared variant vocabulary
425/// (with tones) and every adopting field must match it exactly — that
426/// agreement is what makes cross-kind treatment (badges, views) coherent.
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct RoleDecl {
429    pub name: String,
430    /// Curation intent for the concept, written once for the whole KB.
431    pub doc: String,
432    /// The engine affordance this role maps onto.
433    pub binds: Affordance,
434    /// Badge roles: the shared variants, each with its tone.
435    #[serde(default, skip_serializing_if = "Vec::is_empty")]
436    pub variants: Vec<Variant>,
437}
438
439/// The engine's closed affordance vocabulary — what generic UI can DO with
440/// a field. Never contains a domain word (that is the KB's registry `roles`
441/// job); grows one affordance at a time, each justified by an engine
442/// behaviour that cannot exist without it.
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444pub enum Affordance {
445    /// Names the record — lists, links, briefs.
446    Title,
447    /// Places the record on the KB's timeline — the sort key, the recency
448    /// signal. (The alias keeps registries written before the rename
449    /// parseable; regeneration writes `Timeline`.)
450    #[serde(alias = "Date")]
451    Timeline,
452    /// A closed enum rendered as a tone-coloured badge.
453    Badge,
454}
455
456/// Valence of one badge variant — the engine's palette; the KB decides
457/// which of its own words map onto which tone.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
459pub enum Tone {
460    Positive,
461    Neutral,
462    Attention,
463    Negative,
464}
465
466/// One kind of record.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct Kind {
469    /// Singular kind name — also the `Link` kind (`person`, `meeting`, …).
470    pub name: String,
471    /// Kind-level doc: what this is AND the existence bar (curation intent).
472    pub doc: String,
473    /// Storage pattern relative to the KB root, placeholders in `{}`
474    /// (e.g. `facts/people/{id}.ron`, `facts/checkins/{person}/{date}.ron`).
475    pub storage: String,
476    pub fields: Vec<Field>,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct Field {
481    pub name: String,
482    /// The field's doc comment — write discipline, semantics, examples.
483    pub doc: String,
484    pub ty: FieldType,
485    /// Name of a KB-declared role this field adopts (Registry::roles).
486    /// Strictly checked — see Registry::coherence_errors.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub role: Option<String>,
489    /// For plain string fields that hold another kind's bare id (a
490    /// check-in's `person` slug): the target kind. REFERENCE semantics —
491    /// the graph builds an edge from it; schema-declared, engine-generic.
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub refers_to: Option<String>,
494}
495
496/// The structural type of a field — what generic UI needs to render and
497/// generic query needs to filter. Deliberately smaller than Rust's type
498/// system: the validation fn owns full fidelity.
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
500pub enum FieldType {
501    /// Plain string.
502    Str,
503    /// Signed integer (i64 on the wire).
504    Int,
505    /// EXACT decimal, carried as a string on the wire (`"1250000.00"`) —
506    /// never a float. The type for money amounts, quantities, rates.
507    Decimal,
508    /// An external web link with a human title, rendered as an anchor.
509    /// On the wire: `(title: "PMO Tracker", url: "https://…")`. Distinct
510    /// from `Link`, which addresses records INSIDE the knowledge graph.
511    Hyperlink,
512    /// Prose rendered as the KB's markdown subset (inline `[[links]]`).
513    Markdown,
514    Date,
515    Bool,
516    /// Closed sum type. Unit-only enums are the degenerate case (every
517    /// variant's `fields` empty); variants with payloads carry their fields,
518    /// so generic UI can render a variant selector + that variant's fields.
519    Enum(Vec<Variant>),
520    /// A `Link`; `allowed: None` = any kind.
521    Link {
522        allowed: Option<Vec<String>>,
523    },
524    List(Box<FieldType>),
525    Option(Box<FieldType>),
526    /// Nested record (e.g. an action's dated log entries).
527    Struct(Vec<Field>),
528}
529
530/// One variant of an `Enum` field (or of a Badge role's shared vocabulary,
531/// where `tone` is meaningful).
532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
533pub struct Variant {
534    pub name: String,
535    #[serde(default, skip_serializing_if = "String::is_empty")]
536    pub doc: String,
537    /// Payload fields; empty = unit variant.
538    #[serde(default, skip_serializing_if = "Vec::is_empty")]
539    pub fields: Vec<Field>,
540    /// Badge-role vocabularies only: the variant's valence.
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub tone: Option<Tone>,
543}