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 keep-health constraints (the constraint vocabulary —
97    /// see [`ConstraintDef`]). Empty default: a schema declaring no
98    /// constraints behaves byte-identically to before the vocabulary
99    /// existed.
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub constraints: Vec<ConstraintDef>,
102    /// The type's declared due axis (see [`DueAxis`]) — absent for
103    /// types without deadline semantics; a schema without any `due:`
104    /// declaration behaves byte-identically to before the axis
105    /// existed.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub due: Option<DueAxis>,
108    // Populated by loader from schema-level defaults merged with
109    // edge_weight_overrides. Skipped during serialization so the on-disk
110    // form round-trips.
111    #[serde(skip)]
112    pub edge_weights: IndexMap<String, f32>,
113    /// Raw author-declared metadata-field keys, recorded by the loader
114    /// BEFORE the base-metadata merge injects the engine fields
115    /// (`type`, `created_date`, `last_modified`, `tags`). The
116    /// install-path reserved-key check
117    /// ([`crate::loader::check_reserved_metadata_keys`]) reads this
118    /// list so it can refuse an author-declared reserved key without
119    /// false-positives on the injected ones. Skipped during
120    /// serialization so the on-disk form round-trips.
121    #[serde(skip)]
122    pub declared_metadata_keys: Vec<String>,
123}
124
125/// One outgoing-edge requirement block on a type definition. Lists one
126/// or more relationship names and a cardinality constraint they must
127/// jointly satisfy. The schema author groups multiple alternative
128/// relationships into a single block when "any of these" satisfies the
129/// rule (e.g. `[CHOSEN, REJECTED]` together with `at_least_one` would
130/// require at least one outgoing edge across both names — but the
131/// planning schema lists each as its own block instead, so each block
132/// gets its own warning entry).
133#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
134#[serde(deny_unknown_fields)]
135pub struct RequiredOutgoing {
136    /// Edge names the rule applies to. Loader validates each against
137    /// the schema's declared relationship vocabulary; unknown names
138    /// raise `SchemaLoadError::UndeclaredRelationship`.
139    pub relationships: Vec<String>,
140    pub cardinality: RequiredCardinality,
141    /// Constraint severity (form 4 of the constraint vocabulary):
142    /// `warn` (the historical default — health finding +
143    /// `MISSING_REQUIRED_OUTGOING` write-time warning) or `block`
144    /// (write-time refusal when a create/update would land, or a
145    /// relate-remove would leave, the entity below cardinality).
146    #[serde(default)]
147    pub severity: ConstraintSeverity,
148}
149
150/// Uniform severity for the constraint vocabulary — one model across
151/// every constraint form, never five ad-hoc ones. `warn` produces a
152/// health finding only; `block` additionally refuses at write time
153/// (and still surfaces pre-existing violations in health). Severity
154/// applies to every write surface uniformly — operator-mode bypasses
155/// allowlists, never validation.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
157#[serde(rename_all = "snake_case")]
158pub enum ConstraintSeverity {
159    /// Health finding only (and, where a write-time warning exists,
160    /// that warning). The default for every form except uniqueness.
161    #[default]
162    Warn,
163    /// Write-time refusal plus health finding for pre-existing
164    /// violations.
165    Block,
166}
167
168impl ConstraintSeverity {
169    /// Serde default for forms whose default tier is `block`
170    /// (uniqueness — plenum 4's 37 duplicates are the evidence).
171    pub fn block() -> Self {
172        Self::Block
173    }
174}
175
176/// One declared keep-health constraint on a type — the constraint
177/// vocabulary (agent-toolbox plan 07). Declarations travel sealed with
178/// the schema package and are rendered on the `memstead_schema`
179/// response at BOTH verbosity levels (a hidden legality condition is a
180/// defect class of its own). The `kind` tag is closed: an unknown kind
181/// fails deserialization, so no declaration can load and be silently
182/// ignored. Forms land vertically — a form is only declarable once the
183/// engine evaluates it.
184#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
185#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
186pub enum ConstraintDef {
187    /// Form 1 — conditional requirement: `field` (a metadata field or
188    /// section key of this type) is required whenever `when_field`
189    /// holds `when_value` ("`status: checked` requires `checked_by`").
190    RequiresWhen {
191        /// The field or section that becomes required.
192        field: String,
193        /// The metadata field whose value triggers the requirement.
194        when_field: String,
195        /// The triggering value (validated against `when_field`'s
196        /// enum, when it declares one).
197        when_value: String,
198        #[serde(default)]
199        severity: ConstraintSeverity,
200    },
201    /// Form 2 — uniqueness: the tuple of metadata-field values named
202    /// in `fields` is unique among entities of this type within one
203    /// mem. Entities missing any of the fields carry no tuple and are
204    /// not compared. Defaults to `block` — the whole point of the
205    /// declaration is preventing the duplicate at write time.
206    Unique {
207        /// The metadata fields forming the unique tuple (each must be
208        /// a declared metadata field of this type).
209        fields: Vec<String>,
210        #[serde(default = "ConstraintSeverity::block")]
211        severity: ConstraintSeverity,
212    },
213    /// Form 3 — enum-from-neighbour: the legal values of `field` are
214    /// the bullet-list entries (`- value` lines) of the `section`
215    /// section on the entity reached from this one via a `rel_type`
216    /// edge. A set value with no backing entry in any reached
217    /// neighbour's section — including the no-neighbour and
218    /// missing-section cases, where nothing can back it — is a
219    /// violation.
220    EnumFromNeighbour {
221        /// The metadata field whose values the neighbour enumerates.
222        field: String,
223        /// The outgoing rel-type that reaches the enumerating entity.
224        rel_type: String,
225        /// The section key on the reached entity whose bullet entries
226        /// are the legal values.
227        section: String,
228        #[serde(default)]
229        severity: ConstraintSeverity,
230    },
231    /// Form 5 — status propagation: when `field` on an entity of this
232    /// type holds `value` (the terminal value), every entity reaching
233    /// it — transitively — via `rel_type` edges in `direction` is
234    /// tainted; tainted entities surface as health findings naming
235    /// their tainting ancestor. Always warn-tier: the taint arises
236    /// from the ancestor's *later* change, so it can never refuse the
237    /// descendant's historical write (the loader refuses a `block`
238    /// declaration on this form rather than accepting a promise the
239    /// engine will not keep).
240    StatusPropagation {
241        /// The status metadata field on this (the tainting) type.
242        field: String,
243        /// The terminal value that starts the taint (validated
244        /// against `field`'s enum, when it declares one).
245        value: String,
246        /// The rel-type the taint travels along.
247        rel_type: String,
248        /// Which direction reaches the dependents: `incoming` taints
249        /// the entities whose `rel_type` edges point at the terminal
250        /// entity (and their dependents, transitively); `outgoing`
251        /// the entities the terminal entity points at.
252        direction: PropagationDirection,
253        #[serde(default)]
254        severity: ConstraintSeverity,
255    },
256}
257
258/// Traversal direction for [`ConstraintDef::StatusPropagation`].
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
260#[serde(rename_all = "snake_case")]
261pub enum PropagationDirection {
262    Incoming,
263    Outgoing,
264}
265
266/// Required-cardinality variants. `AtLeastOne` is the only variant
267/// shipped initially; `ExactlyOne` is the obvious next variant but is
268/// not yet wired.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
270#[serde(rename_all = "snake_case")]
271pub enum RequiredCardinality {
272    AtLeastOne,
273}
274
275impl std::fmt::Display for RequiredCardinality {
276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        f.write_str(match self {
278            RequiredCardinality::AtLeastOne => "at_least_one",
279        })
280    }
281}
282
283impl RequiredOutgoing {
284    /// Returns `true` iff `outgoing_count` of edges across the block's
285    /// `relationships` list satisfies the declared cardinality.
286    pub fn admits(&self, outgoing_count: usize) -> bool {
287        match self.cardinality {
288            RequiredCardinality::AtLeastOne => outgoing_count >= 1,
289        }
290    }
291}
292
293impl TypeDefinition {
294    /// Look up an edge weight by relationship name. Falls back to `_default`
295    /// when the relationship is unknown; returns `1.0` only if `_default`
296    /// itself is missing (the loader normally guarantees it exists).
297    pub fn edge_weight(&self, rel: &str) -> f32 {
298        if let Some(&w) = self.edge_weights.get(rel) {
299            return w;
300        }
301        if let Some(&w) = self.edge_weights.get("_default") {
302            return w;
303        }
304        1.0
305    }
306
307    pub fn section(&self, key: &str) -> Option<&SectionDef> {
308        self.sections.iter().find(|s| s.key == key)
309    }
310
311    pub fn catch_all_section(&self) -> Option<&SectionDef> {
312        self.sections.iter().find(|s| s.catch_all)
313    }
314
315    pub fn metadata_field(&self, key: &str) -> Option<&MetadataFieldDef> {
316        self.metadata_fields.iter().find(|f| f.key == key)
317    }
318
319    /// Closest declared metadata-field key for a typo, used by the CRUD layer
320    /// to build a "did you mean ..." hint when rejecting an unknown key.
321    pub fn suggest_metadata_field(&self, key: &str) -> Option<String> {
322        crate::schema::closest_match(key, self.metadata_fields.iter().map(|f| f.key.as_str()))
323    }
324
325    /// Closest declared section key for a typo. Used by the CRUD layer to
326    /// build the `UNKNOWN_SECTION` envelope's `suggestion` field on inbound
327    /// create/update writes.
328    pub fn suggest_section(&self, key: &str) -> Option<String> {
329        crate::schema::closest_match(key, self.sections.iter().map(|s| s.key.as_str()))
330    }
331
332    /// Required sections in declaration order.
333    pub fn required_sections(&self) -> impl Iterator<Item = &SectionDef> {
334        self.sections.iter().filter(|s| s.required)
335    }
336
337    /// Optional sections in declaration order.
338    pub fn optional_sections(&self) -> impl Iterator<Item = &SectionDef> {
339        self.sections.iter().filter(|s| !s.required)
340    }
341
342    /// System message as a string — empty if unset.
343    pub fn system_message_str(&self) -> &str {
344        self.system_message.as_deref().unwrap_or("")
345    }
346}
347
348/// One canonical exemplar entity for a type — a complete entity in the
349/// mem markdown shape: title, metadata overrides, section bodies, and
350/// relationship entries with placeholder targets. Engine-validated at
351/// schema install/seal through the real create path, so it can never
352/// teach a shape the validator would refuse.
353#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
354#[serde(deny_unknown_fields)]
355pub struct Exemplar {
356    /// The exemplar entity's title (drives the id slug exactly as a
357    /// real create would).
358    pub title: String,
359    /// Metadata overrides, keyed by declared field key — validated
360    /// like a real create's metadata (enums included). Engine-stamped
361    /// fields (`created_date`, …) are omitted; the engine fills them.
362    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
363    pub metadata: IndexMap<String, String>,
364    /// Section bodies keyed by section key. Required sections must all
365    /// be present — the validator enforces it like any create.
366    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
367    pub sections: IndexMap<String, String>,
368    /// Relationship entries with PLACEHOLDER targets: each `to` is a
369    /// bare slug (no `mem--` prefix — an exemplar lives outside any
370    /// mem); validation checks rel-type legality and shape, never
371    /// target existence.
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub relations: Vec<ExemplarRelation>,
374}
375
376/// One relationship entry on an [`Exemplar`].
377#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
378#[serde(deny_unknown_fields)]
379pub struct ExemplarRelation {
380    /// Placeholder target: a bare slug, scoped to the exemplar's own
381    /// (virtual) mem at validation time.
382    pub to: String,
383    /// Relationship type (UPPER_SNAKE_CASE; validated against the
384    /// schema's declared vocabulary).
385    #[serde(rename = "type")]
386    pub rel_type: String,
387    /// Optional per-edge description — validated against the
388    /// rel-type's `per_edge_description` posture.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub description: Option<String>,
391}
392
393/// Derive the storage key a `## Heading` line resolves to.
394///
395/// The single owner of the heading→key mapping: the entity parser uses it
396/// to place parsed section content, and the schema loader's round-trip
397/// check uses it to refuse schemas whose declared headings could never
398/// find their way back to their declared keys. A second copy of this
399/// logic is how silent section forks return — both sides must call this
400/// function.
401///
402/// The mapping is deliberately narrow: lowercase the heading, replace
403/// spaces with underscores. Anything looser (slugging punctuation,
404/// folding diacritics) would let two distinct declared sections collide
405/// on one key, trading a visible refusal for an invisible content merge.
406pub fn derive_section_key(heading: &str) -> String {
407    heading.to_lowercase().replace(' ', "_")
408}
409
410/// A section within an entity (e.g. "Claim", "Evidence").
411#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
412#[serde(deny_unknown_fields)]
413pub struct SectionDef {
414    pub key: String,
415    pub heading: String,
416    /// Whether an entity must carry this section — **absence means
417    /// optional**, the same rule metadata fields follow. `required:
418    /// true` refuses a create without the section
419    /// (`MISSING_REQUIRED_SECTION`).
420    #[serde(default)]
421    pub required: bool,
422    pub search_weight: f32,
423    #[serde(default)]
424    pub catch_all: bool,
425    #[serde(default)]
426    pub write_rules: Vec<String>,
427    #[serde(default)]
428    pub description: Option<String>,
429    /// Declared markdown shape (section-format vocabulary, plan 08):
430    /// a flat content expression over the mdast block vocabulary —
431    /// see [`crate::content_expr::ContentExpr`]. Absent = free-form,
432    /// exactly the pre-declaration behavior. Validated and compiled
433    /// at schema load ([`SectionDef::compiled_content`]).
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub content: Option<String>,
436    /// Regex applied to the repeating unit of the declared `content`
437    /// (list items with lazy continuation joined; paragraph source
438    /// lines). Implicitly anchored `^…$`; named capture groups name
439    /// the parts in refusal payloads. Legal only when `content`
440    /// contains exactly one of `list` / `paragraph`.
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub item_pattern: Option<String>,
443    /// Table contract — only legal when `content` contains `table`.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub table: Option<TableFormat>,
446    /// One conforming snippet, echoed verbatim in every format
447    /// refusal — for an agent, a conforming example outperforms any
448    /// grammar string.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub example: Option<String>,
451    /// Severity of format violations (plan 07's uniform model).
452    /// Default `block`: a shape violation is deterministic and
453    /// one-round-trip repairable (the enum-value analogy) — `warn`
454    /// stays available per section.
455    #[serde(
456        default = "ConstraintSeverity::block",
457        skip_serializing_if = "severity_is_block"
458    )]
459    pub format_severity: ConstraintSeverity,
460    /// The compiled `content` expression — populated by the loader
461    /// (parse once, match per write). Skipped in serialization so the
462    /// on-disk form round-trips. `None` when no format is declared OR
463    /// the declaration is defective (see `format_problems`).
464    #[serde(skip)]
465    pub compiled_content: Option<crate::content_expr::ContentExpr>,
466    /// Problems the loader found in this section's format declaration.
467    /// Same posture as the reserved-metadata-key check: install and
468    /// strict validation refuse on these
469    /// ([`crate::loader::check_section_formats`]); boot and
470    /// sealed-schema loads do NOT — a sealed schema carrying a bad
471    /// declaration keeps loading (refusing at boot would brick the
472    /// workspace) and the defect surfaces as a health finding. A
473    /// defective declaration is never enforced (`compiled_content`
474    /// stays `None`).
475    #[serde(skip)]
476    pub format_problems: Vec<String>,
477}
478
479fn severity_is_block(s: &ConstraintSeverity) -> bool {
480    *s == ConstraintSeverity::Block
481}
482
483/// The table contract of a format-declared section: `columns` pins
484/// header names and order; `column_patterns` maps column name → regex
485/// per cell (implicitly anchored). Column-count enforcement is ours by
486/// decision — GFM silently pads/truncates short or long rows, so a
487/// row with the wrong cell count is *our* refusal, not the parser's.
488#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
489#[serde(deny_unknown_fields)]
490pub struct TableFormat {
491    pub columns: Vec<String>,
492    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
493    pub column_patterns: IndexMap<String, String>,
494}
495
496/// A type's declared **due axis** (first-author-path plan 08): which
497/// of its fields carry deadline semantics, so the engine's due-brief
498/// (`memstead due`) can render "what is due next" without knowing any
499/// domain vocabulary. Validated at schema load: `date_field` must be
500/// a date-typed metadata field of the type, `status_field` an
501/// enum-typed one, every `open_values` entry a member of that enum,
502/// and `lead_section` (optional — rendered as "what must happen
503/// first") a declared section key. The axis is rendering-only: it
504/// never enforces anything (constraints own enforcement) and the
505/// engine never advances a date (the agent loop is the runtime).
506#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
507#[serde(deny_unknown_fields)]
508pub struct DueAxis {
509    /// Date-typed metadata field holding the deadline.
510    pub date_field: String,
511    /// Enum-typed metadata field holding the lifecycle status.
512    pub status_field: String,
513    /// The `status_field` values under which the entity counts as
514    /// still open (due-relevant). Every entry must be declared in the
515    /// field's `enum_values`.
516    pub open_values: Vec<String>,
517    /// Optional section key whose content renders with each entry as
518    /// "what must happen first".
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub lead_section: Option<String>,
521}
522
523/// A metadata (frontmatter) field.
524#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
525#[serde(deny_unknown_fields)]
526pub struct MetadataFieldDef {
527    pub key: String,
528    pub description: String,
529    pub field_type: FieldType,
530    #[serde(default)]
531    pub default_value: Option<String>,
532    #[serde(default)]
533    pub enum_values: Option<Vec<String>>,
534    /// Whether an entity must carry this field — **absence means
535    /// optional**, the same rule sections follow. `required: true`
536    /// refuses a create that leaves the field unset
537    /// (`REQUIRED_FIELD_UNSET`); a required field with a
538    /// `default_value` (or an `init_timestamp`) is auto-filled and
539    /// therefore never refused — required-with-default means "always
540    /// present", not "caller must type it". Replaces the retired
541    /// `optional:` key (opposite polarity): sealed schemas carrying
542    /// `optional` keep loading with inverted-but-equivalent
543    /// semantics; authoring refuses it naming this key.
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub required: Option<bool>,
546    /// The retired `optional:` key, captured raw so sealed content
547    /// keeps loading (inverted) while authoring refuses it. Never
548    /// serialized, never part of the authoring language.
549    #[serde(default, rename = "optional", skip_serializing)]
550    #[schemars(skip)]
551    pub legacy_optional: Option<bool>,
552    /// Resolved requiredness — computed at load from `required`,
553    /// the retired `optional`, and the package's format generation
554    /// (an unmarked sealed package reads absence as required, the
555    /// legacy meaning; everything else reads absence as optional).
556    /// Read via [`Self::is_required`]; never parsed from YAML.
557    #[serde(skip)]
558    #[schemars(skip)]
559    pub required_resolved: bool,
560    #[serde(default)]
561    pub init_timestamp: bool,
562    #[serde(default)]
563    pub auto_timestamp: bool,
564    #[serde(default)]
565    pub serialization: Serialization,
566    #[serde(default)]
567    pub filterable: Filterable,
568}
569
570impl MetadataFieldDef {
571    /// Whether an entity must carry this field, after the load-time
572    /// polarity resolution. The single read every validator and
573    /// projection uses — `required`/`legacy_optional` are raw parse
574    /// captures, not behaviour.
575    pub fn is_required(&self) -> bool {
576        self.required_resolved
577    }
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
581#[serde(rename_all = "snake_case")]
582pub enum FieldType {
583    String,
584    Number,
585    Date,
586    Boolean,
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
590#[serde(rename_all = "snake_case")]
591pub enum Serialization {
592    #[default]
593    Default,
594    CsvArray,
595    OmitWhenFalsy,
596}
597
598#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
599#[serde(rename_all = "snake_case")]
600pub enum Filterable {
601    #[default]
602    None,
603    Equality,
604    Range,
605}
606
607impl Filterable {
608    /// Agent-facing wire token for this posture, or `None` when the field
609    /// is not filterable. Single source of truth for the string both MCP
610    /// schema projections (`memstead_schema`) emit so an agent reads a field's
611    /// `filters` / `range_filters` eligibility straight from the schema
612    /// body instead of trial-and-error against filter warnings.
613    pub fn as_wire_str(self) -> Option<&'static str> {
614        match self {
615            Filterable::None => None,
616            Filterable::Equality => Some("equality"),
617            Filterable::Range => Some("range"),
618        }
619    }
620}