Skip to main content

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