memstead_schema/types.rs
1//! Schema-as-artifact type format.
2//!
3//! Serde-based, YAML-authorable type definitions.
4
5use indexmap::IndexMap;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// Complete type definition — serde/schemars-based, authored in YAML.
10/// `skip_serializing_if` helper for default-false flags.
11fn is_false(b: &bool) -> bool {
12 !*b
13}
14
15#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
16#[serde(deny_unknown_fields)]
17pub struct TypeDefinition {
18 pub name: String,
19 pub description: String,
20 pub when_to_use: String,
21 #[serde(default)]
22 pub boundaries: Vec<String>,
23 /// One canonical, ENGINE-VALIDATED exemplar entity for this type
24 /// (agent-trust plan 09) — the few-shot material an authoring
25 /// agent actually learns from. Validated against this very type
26 /// through the real create path (`dry_run`) at schema
27 /// install/seal time: a package whose exemplar does not conform
28 /// refuses with a typed error naming the type and the defect —
29 /// there is no warn-and-carry mode, so an exemplar can never
30 /// drift into teaching the wrong shape. Served at
31 /// `verbosity: full` only (the lite skeleton stays unchanged).
32 /// Optional per type; the built-in reference schemas are complete.
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub exemplar: Option<Exemplar>,
35 /// Legacy-key sentinel for the retired `examples:` list — a field
36 /// that promised few-shot teaching but was never validated nor
37 /// served by any surface (dead vocabulary). Authoring contexts
38 /// refuse it with a typed error naming `exemplar`; sealed
39 /// contexts tolerate and drop it. Never serialized.
40 #[serde(default, rename = "examples", skip_serializing)]
41 #[schemars(skip)]
42 pub legacy_examples: Option<serde_yaml_ng::Value>,
43 #[serde(default)]
44 pub system_message: Option<String>,
45 pub sections: Vec<SectionDef>,
46 pub metadata_fields: Vec<MetadataFieldDef>,
47 pub title_weight: f32,
48 pub text_fields: Vec<String>,
49 pub hierarchy_relationship: String,
50 /// The schema's last-resort type: the catch-all an author falls
51 /// back to when no more specific type fits (`spec` in the software
52 /// schemas' own prose). A declaration, so a reader of the model can
53 /// count how much of a cluster sits on it (the `vital_signs` health
54 /// axis) without guessing from names; at most one type per schema
55 /// may declare it (`MultipleLastResortTypes` at load). Default
56 /// false.
57 #[serde(default)]
58 pub last_resort: bool,
59 #[serde(default)]
60 pub edge_weight_overrides: IndexMap<String, f32>,
61 /// Rel-types on which `memstead_relate` refuses a SELF-LOOP
62 /// (from == to) when this type is the source. That refusal is this
63 /// field's ONLY effect — it propagates nothing, implies no
64 /// evidence obligation (real impact propagation is the
65 /// `status_propagation` constraint). Renamed from the misleading
66 /// `propagating_relationships` (agent-trust plan 06): the old key
67 /// refuses at authoring/install load with a typed error naming
68 /// this one; sealed content (built-ins, installed refs) loads
69 /// with the old key translated.
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub no_self_loop_relationships: Vec<String>,
72 /// Legacy-key sentinel: captures a `propagating_relationships:`
73 /// value during deserialization so the loader can refuse (strict
74 /// authoring contexts) or translate (sealed contexts). Never
75 /// serialized; never part of the payload surface.
76 #[serde(default, rename = "propagating_relationships", skip_serializing)]
77 #[schemars(skip)]
78 pub legacy_propagating_relationships: Option<Vec<String>>,
79 /// Terminal-by-construction marker: entities of this type are
80 /// leaves — they carry no edges BY DESIGN, so health's orphan
81 /// axis exempts their edge-less entities and reports them as a
82 /// separate leaf population instead (visible, never vanished).
83 /// Leaf means "no edges required", not "edges forbidden": a
84 /// leaf-typed entity WITH edges stays legal, and every other
85 /// health axis, search, and traversal treats leaf entities
86 /// exactly like any other. Declarative per-type flag (the sixth
87 /// declarative form the agent-toolbox constraint vocabulary
88 /// anticipated), served at both schema verbosity levels.
89 #[serde(default, skip_serializing_if = "is_false")]
90 pub leaf: bool,
91 pub updatable_fields: Vec<String>,
92 pub health_required_fields: Vec<String>,
93 pub staleness_threshold_days: u32,
94 pub write_rules: Vec<String>,
95 /// Outgoing-edge invariants the schema asserts for this type.
96 /// Each entry names a list of relationship names plus a cardinality
97 /// constraint. The engine evaluates these on every `memstead_create` /
98 /// `memstead_update` (post-application of inline `relations:` / patches)
99 /// and surfaces unsatisfied blocks as a single
100 /// `MISSING_REQUIRED_OUTGOING` warning per entity. Tier-2 (warn,
101 /// never block). Empty default — types without `required_outgoing`
102 /// keep current behaviour.
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub required_outgoing: Vec<RequiredOutgoing>,
105 /// Declared reachability obligations (see [`MustReach`]): entities
106 /// of this type must reach at least one entity of a named terminal
107 /// type, following edges of a named relation set in a named
108 /// direction, within an optional maximum depth. Health-path only,
109 /// always warn-tier (the loader refuses `block`: a transitive
110 /// property is established by writes on OTHER entities, so a
111 /// write-time refusal would punish the wrong mutation). Empty
112 /// default keeps current behaviour.
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub must_reach: Vec<MustReach>,
115 /// Declared aggregate signals (see [`SignalDef`]): exact,
116 /// parameter-free counts with declared thresholds, computed at
117 /// read time and served with their evidence — never scored,
118 /// never blocking, never stored. Empty default keeps every
119 /// response byte-identical.
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub signals: Vec<SignalDef>,
122 /// Declared keep-health constraints (the constraint vocabulary —
123 /// see [`ConstraintDef`]). Empty default: a schema declaring no
124 /// constraints behaves byte-identically to before the vocabulary
125 /// existed.
126 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub constraints: Vec<ConstraintDef>,
128 /// The type's declared due axis (see [`DueAxis`]) — absent for
129 /// types without deadline semantics; a schema without any `due:`
130 /// declaration behaves byte-identically to before the axis
131 /// existed.
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub due: Option<DueAxis>,
134 // Populated by loader from schema-level defaults merged with
135 // edge_weight_overrides. Skipped during serialization so the on-disk
136 // form round-trips.
137 #[serde(skip)]
138 pub edge_weights: IndexMap<String, f32>,
139 /// Raw author-declared metadata-field keys, recorded by the loader
140 /// BEFORE the base-metadata merge injects the engine fields
141 /// (`type`, `created_date`, `last_modified`, `tags`). The
142 /// install-path reserved-key check
143 /// ([`crate::loader::check_reserved_metadata_keys`]) reads this
144 /// list so it can refuse an author-declared reserved key without
145 /// false-positives on the injected ones. Skipped during
146 /// serialization so the on-disk form round-trips.
147 #[serde(skip)]
148 pub declared_metadata_keys: Vec<String>,
149}
150
151/// One outgoing-edge requirement block on a type definition. Lists one
152/// or more relationship names and a cardinality constraint they must
153/// jointly satisfy. The schema author groups multiple alternative
154/// relationships into a single block when "any of these" satisfies the
155/// rule (e.g. `[CHOSEN, REJECTED]` together with `at_least_one` would
156/// require at least one outgoing edge across both names — but the
157/// planning schema lists each as its own block instead, so each block
158/// gets its own warning entry).
159#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
160#[serde(deny_unknown_fields)]
161pub struct RequiredOutgoing {
162 /// Edge names the rule applies to. Loader validates each against
163 /// the schema's declared relationship vocabulary; unknown names
164 /// raise `SchemaLoadError::UndeclaredRelationship`.
165 pub relationships: Vec<String>,
166 pub cardinality: RequiredCardinality,
167 /// Constraint severity (form 4 of the constraint vocabulary):
168 /// `warn` (the historical default — health finding +
169 /// `MISSING_REQUIRED_OUTGOING` write-time warning) or `block`
170 /// (write-time refusal when a create/update would land, or a
171 /// relate-remove would leave, the entity below cardinality).
172 #[serde(default)]
173 pub severity: ConstraintSeverity,
174 /// Optional condition: the block applies only when this metadata
175 /// field of the entity holds `when_value`. The same two keys
176 /// `requires_when` uses — one vocabulary for one idea. The loader
177 /// requires the pair to appear together, `when_field` to name a
178 /// declared metadata field of this type carrying `enum_values`,
179 /// and `when_value` to be a member. Absent pair = unconditional
180 /// block = long-standing behaviour.
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub when_field: Option<String>,
183 /// The triggering value (see `when_field`).
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub when_value: Option<String>,
186}
187
188/// One reachability obligation on a type definition. The obligated
189/// entity must reach at least one non-stub entity whose type is in
190/// `terminal_types`, walking edges whose rel-type is in
191/// `relationships` (an inline relation set), in `direction`, within
192/// `max_depth` hops when bounded. Evaluated on the health sweep only
193/// (`constraints` axis), never on the write path — no single write
194/// completes a transitive absence. The incoming direction with
195/// `max_depth: 1` covers the required-incoming-edge case.
196#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
197#[serde(deny_unknown_fields)]
198pub struct MustReach {
199 /// Edge names the walk follows — any name in the set continues the
200 /// path. Loader validates each against the schema's relationship
201 /// vocabulary.
202 pub relationships: Vec<String>,
203 /// `out` follows edges pointing away from the walked entity, `in`
204 /// follows edges pointing at it — the same vocabulary the store
205 /// and `memstead_search` speak.
206 pub direction: ReachDirection,
207 /// Type names that satisfy the obligation when reached. Loader
208 /// validates each against the schema's declared types.
209 pub terminal_types: Vec<String>,
210 /// Maximum number of hops a conforming path may take. Absent =
211 /// unbounded. Zero refuses at load (nothing is reachable in zero
212 /// hops).
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub max_depth: Option<u32>,
215 /// Always `warn` — the loader refuses `block` on this form.
216 #[serde(default)]
217 pub severity: ConstraintSeverity,
218}
219
220/// One declared aggregate signal on a type definition. This wave
221/// ships one kind, `edge_load`: count the edges of a named rel-type
222/// set, in a named direction, on entities of this type, optionally
223/// restricted to edges whose counterpart entity holds a named enum
224/// value. Thresholds map counts to levels; below the first threshold
225/// the served level is `none`. Values are computed at read time in
226/// O(degree), never stored, never part of `_hash`; a signal may not
227/// reference another signal, and nothing multiplies, averages, or
228/// decays — a count and a threshold are the whole vocabulary.
229#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
230#[serde(deny_unknown_fields)]
231pub struct SignalDef {
232 /// Unique per type, `[a-z][a-z0-9_]*` — the stable name prose
233 /// and consumers bind guidance to.
234 pub name: String,
235 /// Closed kind enum — one member in this wave.
236 pub kind: SignalKind,
237 /// Edge names the count covers (inline relation set; loader
238 /// validates each against the vocabulary).
239 pub relationships: Vec<String>,
240 /// `in` counts edges pointing at the entity, `out` edges pointing
241 /// away — the same vocabulary the store and search speak.
242 pub direction: ReachDirection,
243 /// Optional counterpart filter, whole or not at all: count only
244 /// edges whose counterpart entity holds `neighbour_value` in
245 /// `neighbour_field`. A counterpart lacking the field or holding
246 /// another value simply does not count.
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub neighbour_field: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub neighbour_value: Option<String>,
251 /// Non-empty, strictly increasing `at_least` values.
252 pub thresholds: Vec<SignalThreshold>,
253}
254
255impl SignalDef {
256 /// The served level for a count: the highest threshold whose
257 /// `at_least` the count meets, `None` below the first (wire level
258 /// `none`).
259 pub fn level_for(&self, count: u64) -> Option<SignalLevel> {
260 self.thresholds
261 .iter()
262 .rev()
263 .find(|t| count >= t.at_least)
264 .map(|t| t.level)
265 }
266}
267
268/// Closed signal-kind vocabulary. Adding a member is a
269/// format-generation event.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
271#[serde(rename_all = "snake_case")]
272pub enum SignalKind {
273 EdgeLoad,
274}
275
276/// One threshold step of a signal declaration.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
278#[serde(deny_unknown_fields)]
279pub struct SignalThreshold {
280 pub at_least: u64,
281 pub level: SignalLevel,
282}
283
284/// Signal levels — deliberately NOT [`ConstraintSeverity`]: a signal
285/// level is the output of a threshold, not the severity of a
286/// violation. `warn` participates in `health --strict` like a
287/// warn-tier constraint finding; `notice` never does — that is the
288/// whole difference between the two.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
290#[serde(rename_all = "snake_case")]
291pub enum SignalLevel {
292 Notice,
293 Warn,
294}
295
296impl std::fmt::Display for SignalLevel {
297 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298 f.write_str(match self {
299 SignalLevel::Notice => "notice",
300 SignalLevel::Warn => "warn",
301 })
302 }
303}
304
305/// Walk direction for [`MustReach`] — wire literals `out` / `in`,
306/// matching the store's relationship rendering and `memstead_search`'s
307/// `direction` parameter.
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
309#[serde(rename_all = "snake_case")]
310pub enum ReachDirection {
311 Out,
312 In,
313}
314
315impl std::fmt::Display for ReachDirection {
316 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317 f.write_str(match self {
318 ReachDirection::Out => "out",
319 ReachDirection::In => "in",
320 })
321 }
322}
323
324/// Uniform severity for the constraint vocabulary — one model across
325/// every constraint form, never five ad-hoc ones. `warn` produces a
326/// health finding only; `block` additionally refuses at write time
327/// (and still surfaces pre-existing violations in health). Severity
328/// applies to every write surface uniformly — operator-mode bypasses
329/// allowlists, never validation.
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
331#[serde(rename_all = "snake_case")]
332pub enum ConstraintSeverity {
333 /// Health finding only (and, where a write-time warning exists,
334 /// that warning). The default for every form except uniqueness.
335 #[default]
336 Warn,
337 /// Write-time refusal plus health finding for pre-existing
338 /// violations.
339 Block,
340}
341
342impl ConstraintSeverity {
343 /// Serde default for forms whose default tier is `block`
344 /// (uniqueness — plenum 4's 37 duplicates are the evidence).
345 pub fn block() -> Self {
346 Self::Block
347 }
348}
349
350/// One declared keep-health constraint on a type — the constraint
351/// vocabulary (agent-toolbox plan 07). Declarations travel sealed with
352/// the schema package and are rendered on the `memstead_schema`
353/// response at BOTH verbosity levels (a hidden legality condition is a
354/// defect class of its own). The `kind` tag is closed: an unknown kind
355/// fails deserialization, so no declaration can load and be silently
356/// ignored. Forms land vertically — a form is only declarable once the
357/// engine evaluates it.
358#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
359#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
360pub enum ConstraintDef {
361 /// Form 1 — conditional requirement: `field` (a metadata field or
362 /// section key of this type) is required whenever `when_field`
363 /// holds `when_value` ("`status: checked` requires `checked_by`").
364 RequiresWhen {
365 /// The field or section that becomes required.
366 field: String,
367 /// The metadata field whose value triggers the requirement.
368 when_field: String,
369 /// The triggering value (validated against `when_field`'s
370 /// enum, when it declares one).
371 when_value: String,
372 #[serde(default)]
373 severity: ConstraintSeverity,
374 },
375 /// Form 2 — uniqueness: the tuple of metadata-field values named
376 /// in `fields` is unique among entities of this type within one
377 /// mem. Entities missing any of the fields carry no tuple and are
378 /// not compared. Defaults to `block` — the whole point of the
379 /// declaration is preventing the duplicate at write time.
380 Unique {
381 /// The metadata fields forming the unique tuple (each must be
382 /// a declared metadata field of this type).
383 fields: Vec<String>,
384 #[serde(default = "ConstraintSeverity::block")]
385 severity: ConstraintSeverity,
386 },
387 /// Form 3 — enum-from-neighbour: the legal values of `field` are
388 /// the bullet-list entries (`- value` lines) of the `section`
389 /// section on the entity reached from this one via a `rel_type`
390 /// edge. A set value with no backing entry in any reached
391 /// neighbour's section — including the no-neighbour and
392 /// missing-section cases, where nothing can back it — is a
393 /// violation.
394 EnumFromNeighbour {
395 /// The metadata field whose values the neighbour enumerates.
396 field: String,
397 /// The outgoing rel-type that reaches the enumerating entity.
398 rel_type: String,
399 /// The section key on the reached entity whose bullet entries
400 /// are the legal values.
401 section: String,
402 #[serde(default)]
403 severity: ConstraintSeverity,
404 },
405 /// Form 5 — status propagation: when `field` on an entity of this
406 /// type holds `value` (the terminal value), every entity reaching
407 /// it — transitively — via `rel_type` edges in `direction` is
408 /// tainted; tainted entities surface as health findings naming
409 /// their tainting ancestor. Always warn-tier: the taint arises
410 /// from the ancestor's *later* change, so it can never refuse the
411 /// descendant's historical write (the loader refuses a `block`
412 /// declaration on this form rather than accepting a promise the
413 /// engine will not keep).
414 StatusPropagation {
415 /// The status metadata field on this (the tainting) type.
416 field: String,
417 /// The terminal value that starts the taint (validated
418 /// against `field`'s enum, when it declares one).
419 value: String,
420 /// The single rel-type the taint travels along. Exactly one
421 /// of `rel_type` / `rel_types` per declaration — the loader
422 /// refuses both-present and neither-present.
423 #[serde(default, skip_serializing_if = "Option::is_none")]
424 rel_type: Option<String>,
425 /// The relation SET the taint travels along — the union
426 /// subgraph, so a taint crosses rel-type boundaries. Inline
427 /// list of declared names, per the bundle-wide convention.
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 rel_types: Option<Vec<String>>,
430 /// Which direction reaches the dependents: `incoming` taints
431 /// the entities whose edges point at the terminal entity (and
432 /// their dependents, transitively); `outgoing` the entities
433 /// the terminal entity points at.
434 direction: PropagationDirection,
435 #[serde(default)]
436 severity: ConstraintSeverity,
437 },
438 /// Form 6 — gated transition: a write that lands `field` holding
439 /// `to_value` requires every entity related via `relationships`
440 /// edges in `direction` to carry a fresh confirming check record
441 /// (derived verification state `checked_ok` — the engine's checks
442 /// substrate; a stale or failed check does not confirm). Generic
443 /// by construction: any schema, any enum field, any relation set —
444 /// no type semantics baked in. Evaluated at write time in the
445 /// shared declared-constraints pass (block refuses, warn warns)
446 /// and reported by the health `constraints` include as a standing
447 /// violation when a check goes stale after the transition. An
448 /// empty related set satisfies the rule (universal quantification
449 /// over nothing); pair with `required_outgoing` where at least one
450 /// related entity must exist. On an engine without a check ledger
451 /// (no workspace root) every related entity derives
452 /// `never_checked`, so a declared gate refuses rather than
453 /// silently passing.
454 TransitionRequiresChecks {
455 /// The metadata field whose value gates (must declare
456 /// `enum_values`; validated by the loader).
457 field: String,
458 /// The gated value — landing it requires the checks.
459 to_value: String,
460 /// Edge names whose related entities must be checked (each
461 /// validated against the relationship vocabulary).
462 relationships: Vec<String>,
463 /// Which side of the edges holds the entities to check:
464 /// `incoming` — entities whose edges point at this one;
465 /// `outgoing` — entities this one points at.
466 direction: PropagationDirection,
467 /// Defaults to `block` — the declaration exists to refuse the
468 /// unverified transition at write time.
469 #[serde(default = "ConstraintSeverity::block")]
470 severity: ConstraintSeverity,
471 },
472}
473
474impl ConstraintDef {
475 /// The effective relation set of a `status_propagation`
476 /// declaration: the single `rel_type` as a one-element list, or
477 /// the declared `rel_types`. The loader guarantees exactly one of
478 /// the two is present. Returns `None` for other constraint forms.
479 pub fn propagation_rel_types(&self) -> Option<Vec<String>> {
480 match self {
481 ConstraintDef::StatusPropagation {
482 rel_type,
483 rel_types,
484 ..
485 } => match (rel_type, rel_types) {
486 (Some(single), None) => Some(vec![single.clone()]),
487 (None, Some(set)) => Some(set.clone()),
488 // Loader-refused shapes; empty keeps callers total.
489 _ => Some(Vec::new()),
490 },
491 _ => None,
492 }
493 }
494}
495
496/// Traversal direction for [`ConstraintDef::StatusPropagation`].
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
498#[serde(rename_all = "snake_case")]
499pub enum PropagationDirection {
500 Incoming,
501 Outgoing,
502}
503
504/// Required-cardinality variants. `AtLeastOne` is the only variant
505/// shipped initially; `ExactlyOne` is the obvious next variant but is
506/// not yet wired.
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
508#[serde(rename_all = "snake_case")]
509pub enum RequiredCardinality {
510 AtLeastOne,
511}
512
513impl std::fmt::Display for RequiredCardinality {
514 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
515 f.write_str(match self {
516 RequiredCardinality::AtLeastOne => "at_least_one",
517 })
518 }
519}
520
521impl RequiredOutgoing {
522 /// Returns `true` iff `outgoing_count` of edges across the block's
523 /// `relationships` list satisfies the declared cardinality.
524 pub fn admits(&self, outgoing_count: usize) -> bool {
525 match self.cardinality {
526 RequiredCardinality::AtLeastOne => outgoing_count >= 1,
527 }
528 }
529}
530
531impl TypeDefinition {
532 /// Look up an edge weight by relationship name. Falls back to `_default`
533 /// when the relationship is unknown; returns `1.0` only if `_default`
534 /// itself is missing (the loader normally guarantees it exists).
535 pub fn edge_weight(&self, rel: &str) -> f32 {
536 if let Some(&w) = self.edge_weights.get(rel) {
537 return w;
538 }
539 if let Some(&w) = self.edge_weights.get("_default") {
540 return w;
541 }
542 1.0
543 }
544
545 pub fn section(&self, key: &str) -> Option<&SectionDef> {
546 self.sections.iter().find(|s| s.key == key)
547 }
548
549 pub fn catch_all_section(&self) -> Option<&SectionDef> {
550 self.sections.iter().find(|s| s.catch_all)
551 }
552
553 pub fn metadata_field(&self, key: &str) -> Option<&MetadataFieldDef> {
554 self.metadata_fields.iter().find(|f| f.key == key)
555 }
556
557 /// Closest declared metadata-field key for a typo, used by the CRUD layer
558 /// to build a "did you mean ..." hint when rejecting an unknown key.
559 pub fn suggest_metadata_field(&self, key: &str) -> Option<String> {
560 crate::schema::closest_match(key, self.metadata_fields.iter().map(|f| f.key.as_str()))
561 }
562
563 /// Closest declared section key for a typo. Used by the CRUD layer to
564 /// build the `UNKNOWN_SECTION` envelope's `suggestion` field on inbound
565 /// create/update writes.
566 pub fn suggest_section(&self, key: &str) -> Option<String> {
567 crate::schema::closest_match(key, self.sections.iter().map(|s| s.key.as_str()))
568 }
569
570 /// Required sections in declaration order.
571 pub fn required_sections(&self) -> impl Iterator<Item = &SectionDef> {
572 self.sections.iter().filter(|s| s.required)
573 }
574
575 /// Optional sections in declaration order.
576 pub fn optional_sections(&self) -> impl Iterator<Item = &SectionDef> {
577 self.sections.iter().filter(|s| !s.required)
578 }
579
580 /// System message as a string — empty if unset.
581 pub fn system_message_str(&self) -> &str {
582 self.system_message.as_deref().unwrap_or("")
583 }
584}
585
586/// One canonical exemplar entity for a type — a complete entity in the
587/// mem markdown shape: title, metadata overrides, section bodies, and
588/// relationship entries with placeholder targets. Engine-validated at
589/// schema install/seal through the real create path, so it can never
590/// teach a shape the validator would refuse.
591#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
592#[serde(deny_unknown_fields)]
593pub struct Exemplar {
594 /// The exemplar entity's title (drives the id slug exactly as a
595 /// real create would).
596 pub title: String,
597 /// Metadata overrides, keyed by declared field key — validated
598 /// like a real create's metadata (enums included). Engine-stamped
599 /// fields (`created_date`, …) are omitted; the engine fills them.
600 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
601 pub metadata: IndexMap<String, String>,
602 /// Section bodies keyed by section key. Required sections must all
603 /// be present — the validator enforces it like any create.
604 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
605 pub sections: IndexMap<String, String>,
606 /// Relationship entries with PLACEHOLDER targets: each `target` is
607 /// a bare slug (no `mem--` prefix — an exemplar lives outside any
608 /// mem); validation checks rel-type legality and shape, never
609 /// target existence.
610 #[serde(default, skip_serializing_if = "Vec::is_empty")]
611 pub relations: Vec<ExemplarRelation>,
612}
613
614/// One relationship entry on an [`Exemplar`].
615///
616/// Authored in the mutation vocabulary (`target:` / `rel_type:`) so the
617/// exemplar an agent reads is byte-for-byte the shape `memstead_create`
618/// accepts. The retired authoring spelling (`to:` / `type:`) is
619/// captured into the `legacy_*` sentinels for the loader's legality
620/// gate: authoring contexts refuse it with a rename pointer, sealed
621/// content (built-ins, installed refs) is translated so shipped
622/// versions keep loading — install-time strict, sealed-tolerant.
623#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
624#[serde(deny_unknown_fields)]
625pub struct ExemplarRelation {
626 /// Placeholder target: a bare slug, scoped to the exemplar's own
627 /// (virtual) mem at validation time. `Option` only for the sealed
628 /// translate path — the loader guarantees `Some` on every loaded
629 /// schema.
630 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub target: Option<String>,
632 /// Relationship type (UPPER_SNAKE_CASE; validated against the
633 /// schema's declared vocabulary). `Option` only for the sealed
634 /// translate path — the loader guarantees `Some` on every loaded
635 /// schema.
636 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub rel_type: Option<String>,
638 /// Retired spelling of `target:` — loader-gate sentinel, never
639 /// populated on a loaded schema.
640 #[serde(default, rename = "to", skip_serializing_if = "Option::is_none")]
641 #[schemars(skip)]
642 pub legacy_to: Option<String>,
643 /// Retired spelling of `rel_type:` — loader-gate sentinel, never
644 /// populated on a loaded schema.
645 #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
646 #[schemars(skip)]
647 pub legacy_type: Option<String>,
648 /// Optional per-edge description — validated against the
649 /// rel-type's `per_edge_description` posture.
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub description: Option<String>,
652}
653
654impl ExemplarRelation {
655 /// The resolved placeholder target. Loader-guaranteed present on
656 /// any loaded schema; empty string only on a hand-built value that
657 /// bypassed the loader.
658 pub fn target_slug(&self) -> &str {
659 self.target.as_deref().unwrap_or_default()
660 }
661
662 /// The resolved rel-type name. Loader-guaranteed present on any
663 /// loaded schema.
664 pub fn rel_type_name(&self) -> &str {
665 self.rel_type.as_deref().unwrap_or_default()
666 }
667}
668
669/// Derive the storage key a `## Heading` line resolves to.
670///
671/// The single owner of the heading→key mapping: the entity parser uses it
672/// to place parsed section content, and the schema loader's round-trip
673/// check uses it to refuse schemas whose declared headings could never
674/// find their way back to their declared keys. A second copy of this
675/// logic is how silent section forks return — both sides must call this
676/// function.
677///
678/// The mapping is deliberately narrow: lowercase the heading, replace
679/// spaces with underscores. Anything looser (slugging punctuation,
680/// folding diacritics) would let two distinct declared sections collide
681/// on one key, trading a visible refusal for an invisible content merge.
682pub fn derive_section_key(heading: &str) -> String {
683 heading.to_lowercase().replace(' ', "_")
684}
685
686/// A section within an entity (e.g. "Claim", "Evidence").
687#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
688#[serde(deny_unknown_fields)]
689pub struct SectionDef {
690 pub key: String,
691 pub heading: String,
692 /// Whether an entity must carry this section — **absence means
693 /// optional**, the same rule metadata fields follow. `required:
694 /// true` refuses a create without the section
695 /// (`MISSING_REQUIRED_SECTION`).
696 #[serde(default)]
697 pub required: bool,
698 /// Whether this section is **load-bearing** for the entity's claim:
699 /// the part of the entity a dependent conclusion rests on, as
700 /// opposed to notes, context, or bookkeeping. Consumed by the
701 /// `entity-load-bearing` preparation (`memstead-base::preparation`):
702 /// an entity-grain anchor's prepared form is the stable
703 /// serialization of the type's load-bearing sections, so a notes-only
704 /// edit never breaks a dependent's prepared hash while a load-bearing
705 /// edit always does. **Absence means undeclared**: when NO section of
706 /// a type declares this flag, the type's required sections are its
707 /// load-bearing set (and a type with no required sections falls back
708 /// to every section). Declaring `load_bearing: false` on a required
709 /// section is legal and excludes it once any section of the type
710 /// declares the flag.
711 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub load_bearing: Option<bool>,
713 pub search_weight: f32,
714 #[serde(default)]
715 pub catch_all: bool,
716 #[serde(default)]
717 pub write_rules: Vec<String>,
718 #[serde(default)]
719 pub description: Option<String>,
720 /// Declared markdown shape (section-format vocabulary, plan 08):
721 /// a flat content expression over the mdast block vocabulary —
722 /// see [`crate::content_expr::ContentExpr`]. Absent = free-form,
723 /// exactly the pre-declaration behavior. Validated and compiled
724 /// at schema load ([`SectionDef::compiled_content`]).
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub content: Option<String>,
727 /// Regex applied to the repeating unit of the declared `content`
728 /// (list items with lazy continuation joined; paragraph source
729 /// lines). Implicitly anchored `^…$`; named capture groups name
730 /// the parts in refusal payloads. Legal only when `content`
731 /// contains exactly one of `list` / `paragraph`.
732 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub item_pattern: Option<String>,
734 /// Table contract — only legal when `content` contains `table`.
735 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub table: Option<TableFormat>,
737 /// One conforming snippet, echoed verbatim in every format
738 /// refusal — for an agent, a conforming example outperforms any
739 /// grammar string.
740 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub example: Option<String>,
742 /// Severity of format violations (plan 07's uniform model).
743 /// Default `block`: a shape violation is deterministic and
744 /// one-round-trip repairable (the enum-value analogy) — `warn`
745 /// stays available per section.
746 #[serde(
747 default = "ConstraintSeverity::block",
748 skip_serializing_if = "severity_is_block"
749 )]
750 pub format_severity: ConstraintSeverity,
751 /// The compiled `content` expression — populated by the loader
752 /// (parse once, match per write). Skipped in serialization so the
753 /// on-disk form round-trips. `None` when no format is declared OR
754 /// the declaration is defective (see `format_problems`).
755 #[serde(skip)]
756 pub compiled_content: Option<crate::content_expr::ContentExpr>,
757 /// Problems the loader found in this section's format declaration.
758 /// Same posture as the reserved-metadata-key check: install and
759 /// strict validation refuse on these
760 /// ([`crate::loader::check_section_formats`]); boot and
761 /// sealed-schema loads do NOT — a sealed schema carrying a bad
762 /// declaration keeps loading (refusing at boot would brick the
763 /// workspace) and the defect surfaces as a health finding. A
764 /// defective declaration is never enforced (`compiled_content`
765 /// stays `None`).
766 #[serde(skip)]
767 pub format_problems: Vec<String>,
768}
769
770fn severity_is_block(s: &ConstraintSeverity) -> bool {
771 *s == ConstraintSeverity::Block
772}
773
774/// The table contract of a format-declared section: `columns` pins
775/// header names and order; `column_patterns` maps column name → regex
776/// per cell (implicitly anchored). Column-count enforcement is ours by
777/// decision — GFM silently pads/truncates short or long rows, so a
778/// row with the wrong cell count is *our* refusal, not the parser's.
779#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
780#[serde(deny_unknown_fields)]
781pub struct TableFormat {
782 pub columns: Vec<String>,
783 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
784 pub column_patterns: IndexMap<String, String>,
785}
786
787/// A type's declared **due axis** (first-author-path plan 08): which
788/// of its fields carry deadline semantics, so the engine's due-brief
789/// (`memstead due`) can render "what is due next" without knowing any
790/// domain vocabulary. Validated at schema load: `date_field` must be
791/// a date-typed metadata field of the type, `status_field` an
792/// enum-typed one, every `open_values` entry a member of that enum,
793/// and `lead_section` (optional — rendered as "what must happen
794/// first") a declared section key. The axis is rendering-only: it
795/// never enforces anything (constraints own enforcement) and the
796/// engine never advances a date (the agent loop is the runtime).
797#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
798#[serde(deny_unknown_fields)]
799pub struct DueAxis {
800 /// Date-typed metadata field holding the deadline.
801 pub date_field: String,
802 /// Enum-typed metadata field holding the lifecycle status.
803 pub status_field: String,
804 /// The `status_field` values under which the entity counts as
805 /// still open (due-relevant). Every entry must be declared in the
806 /// field's `enum_values`.
807 pub open_values: Vec<String>,
808 /// Optional section key whose content renders with each entry as
809 /// "what must happen first".
810 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub lead_section: Option<String>,
812}
813
814/// A metadata (frontmatter) field.
815#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
816#[serde(deny_unknown_fields)]
817pub struct MetadataFieldDef {
818 pub key: String,
819 pub description: String,
820 pub field_type: FieldType,
821 #[serde(default)]
822 pub default_value: Option<String>,
823 #[serde(default)]
824 pub enum_values: Option<Vec<String>>,
825 /// Whether an entity must carry this field — **absence means
826 /// optional**, the same rule sections follow. `required: true`
827 /// refuses a create that leaves the field unset
828 /// (`REQUIRED_FIELD_UNSET`); a required field with a
829 /// `default_value` (or an `init_timestamp`) is auto-filled and
830 /// therefore never refused — required-with-default means "always
831 /// present", not "caller must type it". Replaces the retired
832 /// `optional:` key (opposite polarity): sealed schemas carrying
833 /// `optional` keep loading with inverted-but-equivalent
834 /// semantics; authoring refuses it naming this key.
835 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub required: Option<bool>,
837 /// The retired `optional:` key, captured raw so sealed content
838 /// keeps loading (inverted) while authoring refuses it. Never
839 /// serialized, never part of the authoring language.
840 #[serde(default, rename = "optional", skip_serializing)]
841 #[schemars(skip)]
842 pub legacy_optional: Option<bool>,
843 /// Resolved requiredness — computed at load from `required`,
844 /// the retired `optional`, and the package's format generation
845 /// (an unmarked sealed package reads absence as required, the
846 /// legacy meaning; everything else reads absence as optional).
847 /// Read via [`Self::is_required`]; never parsed from YAML.
848 #[serde(skip)]
849 #[schemars(skip)]
850 pub required_resolved: bool,
851 #[serde(default)]
852 pub init_timestamp: bool,
853 #[serde(default)]
854 pub auto_timestamp: bool,
855 #[serde(default)]
856 pub serialization: Serialization,
857 #[serde(default)]
858 pub filterable: Filterable,
859}
860
861impl MetadataFieldDef {
862 /// Whether an entity must carry this field, after the load-time
863 /// polarity resolution. The single read every validator and
864 /// projection uses — `required`/`legacy_optional` are raw parse
865 /// captures, not behaviour.
866 pub fn is_required(&self) -> bool {
867 self.required_resolved
868 }
869}
870
871#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
872#[serde(rename_all = "snake_case")]
873pub enum FieldType {
874 String,
875 Number,
876 Date,
877 Boolean,
878}
879
880#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
881#[serde(rename_all = "snake_case")]
882pub enum Serialization {
883 #[default]
884 Default,
885 CsvArray,
886 OmitWhenFalsy,
887}
888
889#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
890#[serde(rename_all = "snake_case")]
891pub enum Filterable {
892 #[default]
893 None,
894 Equality,
895 Range,
896}
897
898impl Filterable {
899 /// Agent-facing wire token for this posture, or `None` when the field
900 /// is not filterable. Single source of truth for the string both MCP
901 /// schema projections (`memstead_schema`) emit so an agent reads a field's
902 /// `filters` / `range_filters` eligibility straight from the schema
903 /// body instead of trial-and-error against filter warnings.
904 pub fn as_wire_str(self) -> Option<&'static str> {
905 match self {
906 Filterable::None => None,
907 Filterable::Equality => Some("equality"),
908 Filterable::Range => Some("range"),
909 }
910 }
911}