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