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