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}