memstead_schema/manifest.rs
1//! Schema manifest (`schema.yaml`) — the outer envelope declaring a schema
2//! package: name, version, type list, relationship vocabulary, community
3//! defaults, and LLM-facing documentation.
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
9#[serde(deny_unknown_fields)]
10pub struct SchemaManifest {
11 pub name: String,
12 /// Semver string — parsed into `semver::Version` by the loader.
13 pub version: String,
14 pub description: String,
15 pub when_to_use: String,
16 #[serde(default)]
17 pub system_message: Option<String>,
18 pub types: Vec<String>,
19 pub relationships: RelationshipVocabulary,
20 pub community: CommunityConfig,
21 /// Schema-generic writing guidance — `avoid` and `goal` prose that
22 /// applies to every mem pinned to this schema. The plugin layer
23 /// concatenates these with per-mem `writeGuidance.avoid_additions`
24 /// / `goal_additions` (an opaque pass-through on the engine side —
25 /// see `MemConfig::write_guidance`'s contract).
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub default_writing_guidance: Option<DefaultWritingGuidance>,
28 /// Outbound cross-mem relationship vocabulary, per target schema
29 /// domain. Each entry names a target schema (bare name — never a
30 /// version; eligibility is name-based) and lists rel-types that may
31 /// cross the boundary in that direction. Absent or `[]` means the
32 /// schema declares no outbound cross-mem edges. Source-ownership
33 /// only — third-party bridge schemas are not modelled; each
34 /// direction is owned by exactly one schema.
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub cross_mem_relationships: Vec<CrossMemRelationshipEntry>,
37 /// Schema-level pointer naming the rel-type that body wiki-links
38 /// `[[target]]` should auto-emit as engine-synthesised relations.
39 /// `None` (default) means the schema is opt-out of alias synthesis
40 /// — unbacked body wiki-links continue to refuse with
41 /// `WIKILINK_WITHOUT_RELATION`. When set, the named rel-type must
42 /// be declared in `relationships.definitions` or schema load
43 /// fails with `SchemaLoadError::AliasTargetRelTypeNotDeclared`.
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub alias_target_rel_type: Option<String>,
46}
47
48/// One outbound cross-mem declaration — a target schema domain
49/// (named, never versioned) and the rel-types admitted in that
50/// direction.
51///
52/// `target_types` strings within each definition live in the target
53/// schema's namespace by construction — the source schema's loader
54/// accepts them as opaque since the target schema may not be present
55/// at source-schema load time.
56#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
57#[serde(deny_unknown_fields)]
58pub struct CrossMemRelationshipEntry {
59 /// Bare name of the target schema — the domain identity. A version
60 /// suffix (`software@1.0.0`) or range (`software@^1.0`) is rejected
61 /// at schema load: cross-mem eligibility is name-based, so the
62 /// declaration is satisfied by a target mem pinning *any* version
63 /// of the named schema.
64 pub to_schema: String,
65 pub definitions: Vec<RelationshipDef>,
66}
67
68/// Schema-level writing-guidance defaults. Both fields are optional so a
69/// schema can ship `avoid` without a `goal` (or vice versa). The engine
70/// surfaces them via `build_schema_payload` at the top level of the
71/// schema-payload JSON; resolution (concatenation with mem additions)
72/// lives in the plugin layer.
73#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct DefaultWritingGuidance {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub avoid: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub goal: Option<String>,
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct RelationshipVocabulary {
85 pub mode: RelationshipMode,
86 pub definitions: Vec<RelationshipDef>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
90#[serde(rename_all = "lowercase")]
91pub enum RelationshipMode {
92 Strict,
93 Open,
94}
95
96#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
97#[serde(deny_unknown_fields)]
98pub struct RelationshipDef {
99 pub name: String,
100 pub description: String,
101 #[serde(default)]
102 pub when_to_use: Option<String>,
103 pub default_weight: f32,
104 /// Per-edge description posture for edges of this rel-type:
105 /// `forbidden` (default) rejects any trailing description text;
106 /// `optional` accepts edges with or without a description;
107 /// `required` rejects edges without a description. The schema
108 /// author opts a catch-all rel-type (e.g. `OTHER`) into
109 /// `required` to force per-edge documentation; most rel-types
110 /// keep the default `forbidden` posture so the rel-type's name
111 /// is the edge's documentation.
112 #[serde(default)]
113 pub per_edge_description: PerEdgeDescription,
114 /// When true, the engine rejects writes that would close a cycle in the
115 /// subgraph restricted to edges of this relationship type. Defaults to
116 /// false so existing user schemas stay opt-in. Semantically meaningless
117 /// on the `_default` sentinel (never a real edge's rel_type).
118 #[serde(default)]
119 pub acyclic: bool,
120 /// When true, edges of this rel-type declare that the SOURCE
121 /// derives from the TARGET (agent-trust plan 12). Exactly three
122 /// effects, warn-tier forever: (1) explicitly writing such an
123 /// edge records the target's current content hash as the edge's
124 /// baseline in the engine-owned derivations sidecar (never in the
125 /// markdown, never in `_hash`); (2) the include-gated
126 /// `stale_derivations` health axis reports every such edge whose
127 /// target's current hash differs from its baseline — and edges
128 /// with no baseline as `unbaselined`, distinctly; (3) a
129 /// duplicate-add `memstead_relate` on such an edge refreshes the
130 /// baseline as its one effect — the agent's explicit "reviewed,
131 /// still holds" — and the response says so. Never a write-block.
132 #[serde(default)]
133 pub derivation: bool,
134 /// Schema-declared types whose entities may be the source of this
135 /// edge. Empty (default) means shape-free — any source type admitted.
136 /// The loader validates every entry against the schema's declared
137 /// types list; unknown names raise `SchemaLoadError::UndeclaredType`.
138 /// At write time, `memstead_relate` rejects shape violations with
139 /// `INVALID_REL_SHAPE`.
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub source_types: Vec<String>,
142 /// Same as `source_types` but for the target side. Empty = shape-free.
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub target_types: Vec<String>,
145 /// Per-source cardinality hint, parsed and stored on the
146 /// relationship definition. Declarative only — the engine does not
147 /// currently enforce it or warn when a relate pushes the source's
148 /// outgoing count for this rel_type outside the declared range.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub cardinality_per_source: Option<Cardinality>,
151 /// Manual-authoring posture for this rel-type. `allow` (default)
152 /// admits explicit `memstead_relate` calls. `warn` lands the relation
153 /// with a `RELATION_MANUAL_AUTHORING_NOT_RECOMMENDED` warning.
154 /// `forbidden` refuses explicit-author calls with the typed
155 /// `RELATION_MANUAL_AUTHORING_FORBIDDEN` code. The body-link →
156 /// relation alias machinery (`memstead_update` / `memstead_create`'s
157 /// wiki-link parser) is NOT gated — schema-emitted relations like
158 /// REFERENCES synthesise unchanged.
159 #[serde(default)]
160 pub manual_authoring: ManualAuthoring,
161}
162
163/// Per-edge description posture declared on a `RelationshipDef`.
164///
165/// `Forbidden` (the default) rejects any trailing description text on
166/// edges of this rel-type. `Optional` accepts both shapes. `Required`
167/// rejects edges without a description — the schema author opts a
168/// catch-all rel-type into this so every edge carries its own
169/// rationale.
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
171#[serde(rename_all = "lowercase")]
172pub enum PerEdgeDescription {
173 #[default]
174 Forbidden,
175 Optional,
176 Required,
177}
178
179/// Manual-authoring posture declared per `RelationshipDef`.
180///
181/// `Allow` (default) is the no-op posture for every rel-type a user
182/// or agent may author explicitly via `memstead_relate`. `Warn` lands the
183/// relation but surfaces a warning so the audit trail records the
184/// drift. `Forbidden` refuses with a typed code — used for rel-types
185/// the engine emits via the body-link → relation alias machinery
186/// (e.g. REFERENCES), where explicit authoring duplicates work and
187/// often masks the author's intent. The schema's `when_to_use` text
188/// rides on the wire as recovery guidance.
189#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
190#[serde(rename_all = "lowercase")]
191pub enum ManualAuthoring {
192 #[default]
193 Allow,
194 Warn,
195 Forbidden,
196}
197
198/// Allowed cardinality ranges for `RelationshipDef::cardinality_per_source`.
199/// Stringly-typed parsing rejected — typos surface at YAML load time via
200/// `serde`, the warning builder gets exhaustive matches, and the wire
201/// payload renders via `Display`.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
203pub enum Cardinality {
204 #[serde(rename = "1")]
205 One,
206 #[serde(rename = "0..1")]
207 ZeroOrOne,
208 #[serde(rename = "1..N")]
209 OneOrMore,
210 #[serde(rename = "0..N")]
211 ZeroOrMore,
212}
213
214impl std::fmt::Display for Cardinality {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 let s = match self {
217 Cardinality::One => "1",
218 Cardinality::ZeroOrOne => "0..1",
219 Cardinality::OneOrMore => "1..N",
220 Cardinality::ZeroOrMore => "0..N",
221 };
222 f.write_str(s)
223 }
224}
225
226impl Cardinality {
227 /// Returns `true` iff `count` falls inside the allowed range. Used by
228 /// `memstead_relate` to predict whether a post-mutation outgoing count
229 /// would violate the schema's intent.
230 pub fn admits(&self, count: usize) -> bool {
231 match self {
232 Cardinality::One => count == 1,
233 Cardinality::ZeroOrOne => count <= 1,
234 Cardinality::OneOrMore => count >= 1,
235 Cardinality::ZeroOrMore => true,
236 }
237 }
238}
239
240/// Community-detection (Louvain) defaults — schema-level, not per-type.
241///
242/// Distinct from the legacy `schemas::CommunityConfig` which was attached to
243/// each `TypeDefinition`; the legacy variant has been removed.
244#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
245#[serde(deny_unknown_fields)]
246pub struct CommunityConfig {
247 pub resolution: f64,
248 pub seed: u32,
249}