memstead_schema/config.rs
1//! Mem configuration loading, validation, and top-level CRUD.
2//!
3//! Handles `.memstead/config.json` parsing, cross-field validation, and the
4//! `update_config_field` write helper. Projections/mediums, their
5//! validators, and the pre-rework migration have been dropped by the
6//! workspace rewrite — `projections` / `mediums`
7//! survive as unknown keys captured into `MemConfig.extra` so legacy
8//! configs still round-trip, but the engine does not interpret them.
9//!
10//! Port of @memstead/config (config-contract.js, index.js) and
11//! @agent-adapters/config-mcp (workspace.js).
12
13use std::collections::{BTreeMap, HashMap};
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19/// The per-mem engine-internal directory under a folder mem's
20/// root — `<mem_root>/.memstead/` holds `config.json` and
21/// `changes.jsonl`. Defined here (rather than in `memstead-base`)
22/// because mem-config loading lives in this crate and `memstead-base`
23/// depends on it; `memstead-base` re-exports the constant for
24/// downstream consumers. Distinct from the workspace store directory
25/// (`memstead_base::WORKSPACE_STORE_DIR`) and from the in-zip member
26/// paths inside sealed archives ([`ARCHIVE_META_DIR`]), which are a
27/// separate on-disk format and never use this constant.
28pub const MEM_META_DIR: &str = ".memstead";
29
30// ---------------------------------------------------------------------------
31// Sealed-archive surface constants
32// ---------------------------------------------------------------------------
33//
34// A sealed archive is a zip whose engine-internal members live under one
35// meta directory: `.memstead/config.json` plus the embedded schema tree
36// `.memstead/schema/…` — the sole member layout. The file extension is
37// `.mem` — the sole spelling, read and written. Defined here because
38// this is the lowest crate every archive reader/writer (memstead-base,
39// memstead-git-branch, memstead-registry, memstead-wasm, the CLIs)
40// already depends on.
41
42/// In-zip meta directory of a sealed archive — the only spelling.
43pub const ARCHIVE_META_DIR: &str = ".memstead";
44/// Member path of the published config inside a sealed archive.
45pub const ARCHIVE_CONFIG_PATH: &str = ".memstead/config.json";
46/// Member-path prefix of the embedded schema tree (manifest at
47/// `<prefix>schema.yaml`, type files under `<prefix>types/`).
48pub const ARCHIVE_SCHEMA_PREFIX: &str = ".memstead/schema/";
49/// Member path of the optional authoring-provenance payload inside a
50/// sealed archive (see [`crate::archive_provenance`]). Additive: archives
51/// predating provenance omit it, and an engine that does not recognise it
52/// tolerates it as an unknown meta member.
53pub const ARCHIVE_PROVENANCE_PATH: &str = ".memstead/provenance.json";
54/// Member path of the optional engine-owned anchors sidecar inside a
55/// sealed archive (the E3a provenance-anchor payload). Additive: archives
56/// with no anchors omit it. Recognised as a first-class member so the
57/// canonical re-pack threads it through verbatim rather than
58/// silently stripping it (a recognised-but-malformed member is a typed
59/// validation failure, unlike unknown future meta members which stay
60/// tolerate-and-ignore).
61pub const ARCHIVE_ANCHORS_PATH: &str = ".memstead/anchors.json";
62/// File extension (without dot) of a sealed archive — the sole spelling.
63/// The one deliberately-distinct token in a project that is otherwise
64/// "memstead" everywhere — short, and derived from the project name.
65pub const ARCHIVE_EXTENSION: &str = "mem";
66
67// ---------------------------------------------------------------------------
68// Error types
69// ---------------------------------------------------------------------------
70
71#[derive(Debug, thiserror::Error)]
72pub enum ConfigError {
73 #[error("config file not found: {0}")]
74 NotFound(String),
75 #[error("invalid JSON in config file: {0}")]
76 InvalidJson(String),
77 #[error("config validation failed:\n{}", .0.iter().map(|e| format!(" - {e}")).collect::<Vec<_>>().join("\n"))]
78 ValidationFailed(Vec<String>),
79 #[error("{0}")]
80 Other(String),
81 #[error("io error: {0}")]
82 Io(#[from] std::io::Error),
83 #[error("json error: {0}")]
84 Json(#[from] serde_json::Error),
85}
86
87// ---------------------------------------------------------------------------
88// Config check result
89// ---------------------------------------------------------------------------
90
91/// Result of config validation — errors are fatal, warnings are informational.
92#[derive(Debug, Clone)]
93pub struct ConfigCheckResult {
94 pub valid: bool,
95 pub errors: Vec<String>,
96 pub warnings: Vec<String>,
97 /// Stable `UPPER_SNAKE_CASE` envelope code when the validator
98 /// detects a categorical failure that callers should branch on.
99 /// Currently set to `"LEGACY_FIELD_PRESENT"` when any entry in
100 /// `LEGACY_TOMBSTONE_KEYS` is present.
101 pub error_code: Option<String>,
102}
103
104// ---------------------------------------------------------------------------
105// Mem config types (deserialized from .memstead/config.json)
106// ---------------------------------------------------------------------------
107
108/// Role-based publish config.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RoleConfig {
111 pub include: Vec<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub exclude: Option<Vec<String>>,
114}
115
116/// Publish config.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct PublishConfig {
119 pub roles: HashMap<String, RoleConfig>,
120}
121
122/// Community detection override.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct CommunityOverride {
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub resolution: Option<f64>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub seed: Option<u32>,
129}
130
131/// One entry in the legacy `MemConfig.read_mems` map — a read-only
132/// sealed mem archive that a pre-2026-08 install attached to a host
133/// mem. Retained only for the one-way boot migration into the mount
134/// roster; no live path writes one.
135///
136/// The engine resolves each entry to a cache file: when `cache_key` is
137/// present, `<mem_cache_dir>/<name>-<cache_key>.mem` (content-addressed
138/// — see [`ReadMemSpec::cache_key`]); otherwise the legacy
139/// `<mem_cache_dir>/<name>.mem`.
140///
141/// Kept as a struct (rather than collapsing to a bare `ReadMemSource`)
142/// so forward-compatible fields can be added without another schema break.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ReadMemSpec {
145 pub source: ReadMemSource,
146 /// Content-address of the installed archive — a short hex digest of
147 /// the validator's canonical bytes. The install path writes the cache
148 /// file at `<cache>/<name>-<cache_key>.mem`, so two distinct archives
149 /// sharing an internal mem name land in distinct files (no collision)
150 /// and re-installing identical bytes resolves to the same file (dedup).
151 /// `None` for legacy registrations written before content-addressing;
152 /// the loader then falls back to the bare `<name>.mem` path.
153 #[serde(default, skip_serializing_if = "Option::is_none", rename = "cacheKey")]
154 pub cache_key: Option<String>,
155}
156
157/// How the app reconstitutes a read mem's cache file when missing.
158///
159/// The engine itself never fetches; `source` is metadata consumed by the
160/// app's installer. A `Registry` variant with scope/name identifiers
161/// will be added once the memstead.io registry ships.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(tag = "type", rename_all = "camelCase")]
164pub enum ReadMemSource {
165 /// User dropped an archive file onto the app. App cannot auto-reinstall —
166 /// it prompts the user to drop the original file again.
167 Local,
168 /// Fetched from an HTTPS URL (GitHub Releases, shared drive, any static
169 /// host). Engine-side no-op; the app's installer re-fetches on attach.
170 Url { url: String },
171 // `Registry` variant reserved for when the memstead.io registry ships.
172 // The exact shape (fields, id format like `@scope/name`) is designed
173 // then — declaring it up front without semantics would be
174 // speculative, and pre-1.0 adding a variant later is not a breaking
175 // change for anyone.
176}
177
178/// Reference to a schema by exact name and version — `name@x.y.z`.
179///
180/// Serializes/deserializes as a single string so mem configs read
181/// `{ "schema": "default@1.0.0" }` on disk. Range syntax (`^`, `~`,
182/// `latest`) is rejected — schema pinning is strict and explicit.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct SchemaRef {
185 pub name: String,
186 pub version: semver::Version,
187}
188
189impl SchemaRef {
190 pub fn new(name: impl Into<String>, version: semver::Version) -> Self {
191 Self {
192 name: name.into(),
193 version,
194 }
195 }
196
197 pub fn as_display(&self) -> String {
198 format!("{}@{}", self.name, self.version)
199 }
200}
201
202impl std::fmt::Display for SchemaRef {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 write!(f, "{}@{}", self.name, self.version)
205 }
206}
207
208impl std::str::FromStr for SchemaRef {
209 type Err = String;
210
211 fn from_str(s: &str) -> Result<Self, Self::Err> {
212 let trimmed = s.trim();
213 if trimmed.is_empty() {
214 return Err("schema reference must not be empty (expected \"name@x.y.z\")".into());
215 }
216 let (name, version_str) = trimmed.split_once('@').ok_or_else(|| {
217 format!(
218 "schema reference '{trimmed}' must include an exact version — expected \"name@x.y.z\""
219 )
220 })?;
221 if name.is_empty() {
222 return Err("schema reference name must not be empty".into());
223 }
224 if version_str == "latest" {
225 return Err(format!(
226 "schema reference '{trimmed}' uses 'latest' — exact semver versions only"
227 ));
228 }
229 if version_str.starts_with(['^', '~', '>', '<', '=', '*']) {
230 return Err(format!(
231 "schema reference '{trimmed}' uses range syntax — exact semver only (e.g. 'default@1.0.0')"
232 ));
233 }
234 let version = semver::Version::parse(version_str).map_err(|e| {
235 format!("schema reference '{trimmed}' has invalid semver version '{version_str}': {e}")
236 })?;
237 Ok(Self {
238 name: name.to_string(),
239 version,
240 })
241 }
242}
243
244impl Serialize for SchemaRef {
245 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
246 serializer.serialize_str(&self.as_display())
247 }
248}
249
250impl<'de> Deserialize<'de> for SchemaRef {
251 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
252 let s = String::deserialize(deserializer)?;
253 s.parse::<SchemaRef>().map_err(serde::de::Error::custom)
254 }
255}
256
257// `MemSchemaPin` (the two-variant pin with a name-only fallback) was
258// retired here. Mem configs now declare a strict `<name>@<version>`
259// pin parsed directly through [`SchemaRef`]; bare-name pins are
260// rejected at config load.
261
262/// VCS layout for a writable mem — optional `{ gitdir, worktree }` pair
263/// in `.memstead/config.json`. When absent, the engine resolves the default:
264/// `.git/` at mem root with `.` as worktree.
265///
266/// Paths are relative to mem root and interpreted by `memstead-git-branch` —
267/// this crate just carries them through serde. Masterplan §3.4 is
268/// explicit that the primitive is a pair of paths; the two canonical
269/// idioms (isolated `{ ".git", "." }` and shared `{ "../.git", ".." }`)
270/// are idioms, not enum variants.
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
272#[serde(rename_all = "camelCase")]
273pub struct VcsConfig {
274 /// Path to the gitdir relative to mem root. Required when the
275 /// `vcs` block is present.
276 pub gitdir: String,
277 /// Path to the worktree relative to mem root. Optional within the
278 /// `vcs` block — defaults to `"."` (mem root) when omitted.
279 #[serde(default = "vcs_worktree_default")]
280 pub worktree: String,
281}
282
283fn vcs_worktree_default() -> String {
284 ".".to_string()
285}
286
287/// Tolerant deserializer for the `vcs` field: accepts the object form
288/// (`{ gitdir, worktree? }`) and returns `None` for any non-object value
289/// (string, number, boolean, null). A missing field is also `None`.
290///
291/// Motivation: an older macOS Mem-mode UI wrote `"vcs": "system"`
292/// (and similar sentinel strings) into `.memstead/config.json` files that
293/// now must continue to load without editing those files by hand.
294/// Strict validation of the object form — unknown keys, missing
295/// `gitdir`, etc. — still surfaces as a hard serde error.
296fn deserialize_vcs_tolerant<'de, D>(deserializer: D) -> Result<Option<VcsConfig>, D::Error>
297where
298 D: serde::Deserializer<'de>,
299{
300 let value = Option::<Value>::deserialize(deserializer)?;
301 match value {
302 None | Some(Value::Null) => Ok(None),
303 Some(Value::Object(_)) => {
304 let v = value.unwrap();
305 Ok(Some(
306 serde_json::from_value(v).map_err(serde::de::Error::custom)?,
307 ))
308 }
309 Some(_) => Ok(None),
310 }
311}
312
313/// The subject block of a mem — what it covers, by what method, and
314/// above all what was considered and deliberately left out. Exactly
315/// three members, by design: every additional slot invites the
316/// working-notes leakage this block exists to avoid, and three is what
317/// a recipient can actually read before deciding whether to trust the
318/// mem. Published verbatim in [`PublishedMemConfig`]; the engine never
319/// parses, links, or validates the prose.
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(deny_unknown_fields, rename_all = "camelCase")]
322pub struct MemSubject {
323 /// What this mem covers.
324 pub scope: String,
325 /// How its content was arrived at.
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub method: Option<String>,
328 /// What was considered and deliberately left out — prose
329 /// statements, order preserved. May be empty.
330 #[serde(default)]
331 pub exclusions: Vec<String>,
332}
333
334/// Engine-owned version stamp of the last successful mutation on a
335/// mem — see [`MemConfig::mutation_stamp`]. Both values are recorded
336/// at mutation time: `engine_version` is the engine crate version the
337/// acting binary was built from, `schema` the resolved
338/// `<name>@<version>` the mutation validated against (the resolved
339/// schema, not merely the pin — mid-migration mems stamp the target
340/// they validated against).
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(deny_unknown_fields, rename_all = "camelCase")]
343pub struct MutationStamp {
344 /// Full build version of the binary that performed the last
345 /// mutation — `memstead-base`'s `build_info::full_version()`:
346 /// the crate semver, plus `+g<sha>[-dirty]` build metadata when
347 /// built inside a git checkout, so dev builds between releases
348 /// stamp distinguishably. Older stamps carry the plain semver
349 /// and compare against the full current value — firing the skew
350 /// hint on the first post-upgrade boot is desired, no migration.
351 pub engine_version: String,
352 /// Resolved schema `<name>@<version>` the last mutation validated
353 /// against.
354 pub schema: String,
355}
356
357/// Full mem configuration loaded from .memstead/config.json.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360pub struct MemConfig {
361 /// Optional mem name. The leaf folder name under
362 /// `__MEMSTEAD:mems/` (and the disk basename on the legacy disk
363 /// path) is authoritative; engine-written configs omit this
364 /// field. Tolerated on read for pre-cutover configs and for the
365 /// [`PublishedMemConfig`] conversion path that still requires
366 /// an explicit identity (the caller passes the name in when
367 /// projecting).
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub name: Option<String>,
370
371 /// Semver version of the mem content. Read at mem-archive export
372 /// time so the engine always knows the current version without manual
373 /// tracking. Parsed at config load — invalid version strings fail fast
374 /// with a source-attributed serde error rather than slipping through to
375 /// export (where the issue only surfaces when a downstream loader tries
376 /// to resolve a `semver::VersionReq` against the mem).
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub version: Option<semver::Version>,
379
380 /// One-line description of the mem, surfaced in mem-archive metadata
381 /// and UI.
382 #[serde(skip_serializing_if = "Option::is_none")]
383 pub description: Option<String>,
384
385 /// Human-readable display title. Display text, NOT identity: no
386 /// slug grammar, no uniqueness rule — the mem name stays the sole
387 /// handle everywhere (paths, grants, namespace patterns, cross-mem
388 /// references, archive filenames). Surfaces that print a mem
389 /// prefer this and fall back to the name. Published in
390 /// [`PublishedMemConfig`].
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub title: Option<String>,
393
394 /// The mem's subject — scope, method, deliberate exclusions.
395 /// Published verbatim; clears as a unit.
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub subject: Option<MemSubject>,
398
399 /// Optional author attribution, surfaced in mem-archive metadata.
400 #[serde(skip_serializing_if = "Option::is_none")]
401 pub authors: Option<Vec<String>>,
402
403 /// Declared process-mem pairing (agent-trust plan 14): the name
404 /// of the mem holding this mem's process tier (verification
405 /// targets, findings, inquiry entries). Declaration wins over the
406 /// binding-name derivation the brief renderer and the
407 /// open-questions health axis otherwise use; a declaration naming
408 /// an unmounted mem surfaces as a typed health finding, never a
409 /// silent fallback. Absent means "derive by convention" — the
410 /// pre-declaration behaviour, unchanged.
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub process_mem: Option<String>,
413
414 /// Schema this mem is pinned to. Exact `<name>@<version>` pin
415 /// only — bare-name forms are rejected at config load. Exactly one
416 /// schema per mem. The `Option` keeps serde tolerant so a missing
417 /// key surfaces as a structured error from `check_config` rather
418 /// than a deserialize panic; a `None` value is a validation error.
419 #[serde(skip_serializing_if = "Option::is_none")]
420 pub schema: Option<SchemaRef>,
421
422 /// Opaque string-map passed through by the engine. Agents and
423 /// plugin prompt renderers are free to invent their own keys; the
424 /// engine does not parse, validate, or interpret any value inside.
425 /// Stripped from `PublishedMemConfig` — guidance is workspace-
426 /// local authorship metadata, not part of the published identity.
427 ///
428 /// Pre-2026-04-24 this field was `Option<Value>`; the workspace
429 /// rewrite normalised it to a map so the shape on
430 /// the wire is stable and the engine's pass-through guarantee is
431 /// type-checked.
432 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
433 pub write_guidance: HashMap<String, Value>,
434 #[serde(skip_serializing_if = "Option::is_none")]
435 pub rules: Option<Value>,
436
437 #[serde(skip_serializing_if = "Option::is_none")]
438 pub publish: Option<PublishConfig>,
439 #[serde(skip_serializing_if = "Option::is_none")]
440 pub language: Option<String>,
441 /// **Legacy since 2026-08.** A read-only sealed-archive mem attaches
442 /// to the WORKSPACE mount roster (`.memstead/state/mounts.json`), not
443 /// to a host mem's config. Nothing writes this field any more; it
444 /// survives on the struct so the one-way boot migration
445 /// (`migrate_legacy_read_mems`) can recognise a pre-cutover
446 /// registration, turn it into a mount, and strip the key. An empty or
447 /// omitted map is the healthy state.
448 ///
449 /// Key is the mem name (matching the archive's config name).
450 ///
451 /// `BTreeMap` (not `HashMap`) so iteration and serialization order
452 /// are stable — reproducible log output and diff-friendly config on
453 /// disk. Explicit `rename = "readMems"` documents the on-disk name
454 /// at the field (the struct-level `rename_all = "camelCase"` already
455 /// handles it, but explicit rename is greppable from either side).
456 #[serde(
457 rename = "readMems",
458 default,
459 skip_serializing_if = "BTreeMap::is_empty"
460 )]
461 pub read_mems: BTreeMap<String, ReadMemSpec>,
462 #[serde(skip_serializing_if = "Option::is_none")]
463 pub community: Option<CommunityOverride>,
464
465 /// Optional VCS layout override. When absent, `memstead-git-branch` resolves
466 /// the default at init time: `.git/` at mem root with `.` as
467 /// worktree. When present, `gitdir` and `worktree` are paths
468 /// relative to the mem root. Stripped from `PublishedMemConfig`
469 /// — VCS layout is workspace-local mechanics, not part of the
470 /// published mem's identity.
471 ///
472 /// Deserialization is tolerant of legacy non-object values (e.g.
473 /// `"vcs": "system"` — the sentinel an older macOS Mem-mode
474 /// UI wrote): any non-object form deserializes to `None` and falls
475 /// back to the default-resolution path. The object form is validated
476 /// strictly.
477 #[serde(
478 default,
479 deserialize_with = "deserialize_vcs_tolerant",
480 skip_serializing_if = "Option::is_none"
481 )]
482 pub vcs: Option<VcsConfig>,
483
484 /// Tombstone marker written by `memstead mem unregister`. ISO-8601
485 /// UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`) recorded at the moment
486 /// the mem was unregistered while its storage was preserved.
487 /// When `memstead mem init <same-name>`
488 /// probes the storage and finds an `unregistered_at` value, it
489 /// treats the residue as deliberate operator state and defaults
490 /// to the `Reattach` recovery action (adopting the preserved
491 /// entities and clearing the tombstone). Absence (`None`) on
492 /// otherwise-present residue triggers `MEM_STORAGE_RESIDUE_DETECTED`
493 /// unless the caller passes an explicit `recovery` flag. Stripped
494 /// from `PublishedMemConfig` — tombstones are workspace-local
495 /// lifecycle state, not part of the published mem's identity.
496 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub unregistered_at: Option<String>,
498
499 /// Per-source "last successfully synced source state", written by
500 /// the ingest layer and surfaced verbatim on the workspace dump.
501 /// The engine never parses, validates, or interprets a value:
502 /// each token is opaque, its meaning owned by the medium-type
503 /// layer that produced it (git → commit id, graph → snapshot
504 /// token, filesystem → a small stat digest the plugin
505 /// JSON-stringifies). The key is likewise opaque — the binding
506 /// layer keys per `(binding, facet)` (conventionally
507 /// `"<binding-id>/<facet>#synced"`, D4), but the engine treats it as
508 /// an arbitrary string. This is the durable, shared baseline against which a
509 /// fresh ingest iteration diffs "what changed since last time";
510 /// it survives a skill-cache wipe and a machine change because it
511 /// lives in engine-held mem config, not ephemeral plugin cache.
512 ///
513 /// Stripped from `PublishedMemConfig` — sync state is
514 /// workspace-local ingest bookkeeping, not part of a published
515 /// mem's identity. `BTreeMap` (not `HashMap`) for stable
516 /// serialization order: diff-friendly config on disk and
517 /// reproducible dump output.
518 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
519 pub sync_state: BTreeMap<String, String>,
520
521 /// The mem's review mark: the last human-approved state, in the
522 /// backend-opaque cursor vocabulary `changes_since` consumes
523 /// (git-branch: commit SHA; folder: changelog RFC3339 timestamp).
524 /// Absent is a first-class state — a mem with no mark is ordinary,
525 /// never an error, and marks never gate writes. One mark per mem;
526 /// wire key `reviewMark` (camelCase per the config's convention).
527 ///
528 /// Stripped from `PublishedMemConfig` (allowlist projection) —
529 /// review state is workspace-collaboration bookkeeping, not part
530 /// of a published mem's identity.
531 #[serde(
532 default,
533 rename = "reviewMark",
534 skip_serializing_if = "Option::is_none"
535 )]
536 pub review_mark: Option<String>,
537
538 /// Engine-owned version stamp of the last successful mutation:
539 /// which engine version and which resolved schema performed it.
540 /// Written by the engine after a mutation and only when the values
541 /// changed (a binary upgrade or a schema repin), never by authors,
542 /// never on read-only loads — a boot writes nothing. Boot compares
543 /// the running binary against the stamp and surfaces a divergence
544 /// as the warn-tier `ENGINE_VERSION_SKEW` hint; absence of a stamp
545 /// is a first-class state (pre-stamp mems), never skew. The stamp
546 /// is the substrate any future migration machinery would consult —
547 /// deliberately built without that machinery.
548 ///
549 /// Stripped from `PublishedMemConfig` (allowlist projection) —
550 /// workspace-local engine bookkeeping, not published identity.
551 /// Wire key `mutationStamp` (camelCase per the config convention).
552 #[serde(
553 default,
554 rename = "mutationStamp",
555 skip_serializing_if = "Option::is_none"
556 )]
557 pub mutation_stamp: Option<MutationStamp>,
558
559 /// Extra fields not in the known set (captured for round-tripping).
560 ///
561 /// Historical tombstones:
562 /// - `defaultSchema` (pre-2026-04): legacy per-mem default type.
563 /// Per-entity `type:` frontmatter is authoritative now.
564 /// - `types: [...]` (pre-schema-artifact, 2026-04): replaced by
565 /// `schema: "<name>@<version>"`. Legacy entries are hard-rejected
566 /// by `check_config`.
567 #[serde(flatten)]
568 pub extra: HashMap<String, Value>,
569}
570
571// ---------------------------------------------------------------------------
572// Published (archive) mem config
573// ---------------------------------------------------------------------------
574
575/// Strict-ingress shape of a mem config. This is the **only** metadata
576/// form that enters a `.mem` archive. `MemConfig` carries author-only
577/// fields (writeGuidance, rules, publish, readMems, language,
578/// community, defaultSchema, vcs, plus any key captured in
579/// `extra`) that never belong in a published archive;
580/// `published_config_from` projects `MemConfig` →
581/// `PublishedMemConfig`, dropping everything outside the whitelist.
582///
583/// `deny_unknown_fields` + no `serde(flatten)` on purpose: the validator
584/// re-parses this shape with the same struct as defense-in-depth, so any
585/// legacy author key smuggled into an archive surfaces as a rejection
586/// instead of a silently-tolerated payload.
587#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(deny_unknown_fields, rename_all = "camelCase")]
589pub struct PublishedMemConfig {
590 pub format: u32,
591 pub name: String,
592 pub version: semver::Version,
593 #[serde(skip_serializing_if = "Option::is_none")]
594 pub description: Option<String>,
595 /// Human-readable display title (format ≥ 4). Display text, not
596 /// identity — a format-3 archive simply has none.
597 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub title: Option<String>,
599 /// The mem's subject block (format ≥ 4), published verbatim —
600 /// exclusions included and in order.
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub subject: Option<MemSubject>,
603 #[serde(skip_serializing_if = "Option::is_none")]
604 pub authors: Option<Vec<String>>,
605 pub schema: SchemaRef,
606}
607
608/// Archive format integer written to the archive config's `format`
609/// field. Bumped to `4` for the mem title + subject block (both
610/// optional — a format-3 archive simply has neither). Readers accept
611/// `3` and `4` via [`published_format_accepted`]; `format: 1` (V1) and
612/// `format: 2` (V2, top-level `schema/` tree) archives keep refusing
613/// cleanly.
614pub const PUBLISHED_MEM_FORMAT: u32 = 4;
615
616/// Every archive format a current reader accepts, newest first: the
617/// current integer plus format 3 (the pre-title/subject shape — the two
618/// mems already published on the live registry stay installable without
619/// a re-publish). `format: 1` (V1) and `format: 2` (V2, top-level
620/// `schema/` tree) keep refusing cleanly.
621///
622/// A list rather than a predicate body because the accepted set is a
623/// fact worth reading directly, not only a branch to evaluate.
624pub const PUBLISHED_MEM_FORMATS_ACCEPTED: &[u32] = &[PUBLISHED_MEM_FORMAT, 3];
625
626/// Does a reader updated for the current format accept an archive at
627/// `format`? The single predicate every reader gate consults —
628/// validation (`validator::config::parse_config_bytes`) and byte
629/// hydration (`Engine::from_archive_bytes`) alike — so acceptance
630/// cannot drift between them.
631pub fn published_format_accepted(format: u32) -> bool {
632 PUBLISHED_MEM_FORMATS_ACCEPTED.contains(&format)
633}
634
635/// Errors returned by `published_config_from`. Actionable messages —
636/// the caller (export pipeline, publish pipeline) surfaces these
637/// directly to the user without wrapping a raw serde error.
638#[derive(Debug, thiserror::Error)]
639pub enum PublishConversionError {
640 #[error("config.version is required for mem publish — set it in .memstead/config.json")]
641 MissingVersion,
642 #[error(
643 "config must declare `schema` (e.g. \"default@1.0.0\") — set it in .memstead/config.json"
644 )]
645 MissingSchema,
646 #[error(
647 "publish requires an explicit mem name — caller must pass the leaf folder name (Goal 3 of mem-repo-restructure dropped the in-config `name` requirement)"
648 )]
649 MissingName,
650}
651
652/// The whitelist projection. Everything author-only is discarded; only
653/// the fields that make sense outside the author's working directory
654/// ride into the archive. `format` is pinned at `PUBLISHED_MEM_FORMAT`.
655///
656/// `name` is supplied explicitly by the caller — the on-disk `name`
657/// field is optional and the engine no longer treats it as the
658/// mem-identity source. The published archive still needs an
659/// identity, so the publishing path passes the leaf folder name
660/// (`__MEMSTEAD:mems/<path>/<leaf>/config.json`'s `<leaf>`, or the
661/// disk basename on the legacy disk path) here. Falls back to the
662/// in-config `name` field when the caller passes an empty string and
663/// the config still carries a legacy `name` value (so pre-cutover
664/// archives published before the migration land cleanly).
665pub fn published_config_from(
666 config: &MemConfig,
667 name: &str,
668) -> Result<PublishedMemConfig, PublishConversionError> {
669 let version = config
670 .version
671 .clone()
672 .ok_or(PublishConversionError::MissingVersion)?;
673 let schema = config
674 .schema
675 .clone()
676 .ok_or(PublishConversionError::MissingSchema)?;
677 let resolved_name = if name.is_empty() {
678 config
679 .name
680 .clone()
681 .ok_or(PublishConversionError::MissingName)?
682 } else {
683 name.to_string()
684 };
685 Ok(PublishedMemConfig {
686 format: PUBLISHED_MEM_FORMAT,
687 name: resolved_name,
688 version,
689 description: config.description.clone(),
690 title: config.title.clone(),
691 subject: config.subject.clone(),
692 authors: config.authors.clone(),
693 schema,
694 })
695}
696
697// ---------------------------------------------------------------------------
698// Constants
699// ---------------------------------------------------------------------------
700
701const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
702 "version",
703 "description",
704 "title",
705 "subject",
706 "authors",
707 "schema",
708 "writeGuidance",
709 "rules",
710 "publish",
711 "language",
712 "readMems",
713 "community",
714 "vcs",
715 "syncState",
716];
717
718/// Keys that are explicitly rejected with a `LEGACY_FIELD_PRESENT`
719/// envelope when present in a config. The validator surfaces a hard
720/// error (not a soft "unknown key" warning) so agents that recreate
721/// the legacy shape from training-set examples see a structured
722/// rejection instead of silent acceptance with drift.
723///
724/// Each entry pairs the rejected key with an actionable error message.
725/// New tombstones land here when a top-level key migrates from
726/// "deprecated but tolerated" to "must not be re-authored". The table
727/// holds entries for `name` (the field is now path-derived),
728/// `types: [...]` (the pre-existing tombstone, preserved verbatim),
729/// `belongsTo` (cross-mem authorization moved to the workspace file),
730/// and `deps` (cross-mem attachments moved to the mount roster).
731const LEGACY_TOMBSTONE_KEYS: &[(&str, &str)] = &[
732 (
733 "types",
734 "Legacy `types: [...]` field detected — replace with `schema: \"<name>@<version>\"` \
735 (e.g. `\"schema\": \"default@1.0.0\"`).",
736 ),
737 (
738 "name",
739 "Legacy `name` field detected — the mem leaf folder under `__MEMSTEAD:mems/` (or the \
740 disk basename on the legacy disk path) is path-derived under the unified layout; \
741 remove the field from `.memstead/config.json`.",
742 ),
743 (
744 "deps",
745 "Legacy `deps` field detected — a cross-mem attachment is a mount now, not a config \
746 list. Remove the field from `.memstead/config.json` and run `memstead install \
747 <scope>/<name>` so the attachment lands in \
748 `.memstead/state/mounts.json`, where the engine reads it.",
749 ),
750 (
751 "belongsTo",
752 "Legacy `belongsTo` field detected — cross-mem authorization moved to the \
753 workspace-level `[cross_mem_links]` section in `.memstead/workspace.toml`. Remove the \
754 field from `.memstead/config.json` and add an entry under `[cross_mem_links]` \
755 instead.",
756 ),
757];
758
759// ---------------------------------------------------------------------------
760// checkConfig — the main validator
761// ---------------------------------------------------------------------------
762
763/// Validate a raw config JSON value. Returns structured errors and warnings.
764pub fn check_config(config: &Value) -> ConfigCheckResult {
765 let mut errors = Vec::new();
766 let mut warnings = Vec::new();
767
768 let obj = match config.as_object() {
769 Some(o) => o,
770 None => {
771 errors.push("(root): config must be an object".to_string());
772 return ConfigCheckResult {
773 valid: false,
774 errors,
775 warnings,
776 error_code: None,
777 };
778 }
779 };
780
781 // 2. Legacy tombstones — keys that must not be re-authored. Each
782 // hit produces a hard error and pins the `LEGACY_FIELD_PRESENT`
783 // envelope code so callers branch on a stable identifier rather
784 // than the human-readable error message. See
785 // `LEGACY_TOMBSTONE_KEYS` for the reject list.
786 let mut legacy_field_hit = false;
787 for (key, message) in LEGACY_TOMBSTONE_KEYS {
788 if obj.contains_key(*key) {
789 errors.push((*message).to_string());
790 legacy_field_hit = true;
791 }
792 }
793
794 // 3. Schema field: exact `name@x.y.z` reference required. Bare-name
795 // pins are rejected at parse time via SchemaRef::from_str.
796 match obj.get("schema") {
797 Some(Value::String(s)) => {
798 if let Err(e) = s.parse::<SchemaRef>() {
799 errors.push(format!("schema: {e}"));
800 }
801 }
802 Some(_) => errors.push(
803 "schema: must be a string of the form \"<name>@<x.y.z>\" \
804 (exact version pin, e.g. \"default@1.0.0\")"
805 .to_string(),
806 ),
807 None => errors.push(
808 "Config must declare `schema` — exact pin of the form \
809 \"<name>@<x.y.z>\" (e.g. \"default@1.0.0\")"
810 .to_string(),
811 ),
812 }
813
814 // 4. Read-mems map — source presence and shape.
815 // Cache-file existence is checked at engine init (`Engine::init`
816 // via `mem_cache`), not here, so isolated schema tests don't
817 // need real archive fixture files. The cached archive's config is
818 // authoritative for the version; no `version` or `path` is
819 // recorded in the config entry.
820 if let Some(Value::Object(mems)) = obj.get("readMems") {
821 for (name, spec) in mems {
822 let entry_path = format!("readMems.{name}");
823
824 let spec_obj = match spec.as_object() {
825 Some(o) => o,
826 None => {
827 errors.push(format!("{entry_path}: read-mem entry must be an object"));
828 continue;
829 }
830 };
831
832 let source = match spec_obj.get("source").and_then(|v| v.as_object()) {
833 Some(s) => s,
834 None => {
835 errors.push(format!(
836 "{entry_path}.source: read-mem entry must declare a source \
837 (e.g. {{\"type\": \"local\"}} or {{\"type\": \"url\", \"url\": \"…\"}})"
838 ));
839 continue;
840 }
841 };
842
843 match source.get("type").and_then(|v| v.as_str()) {
844 Some("local") => {}
845 Some("url") => match source.get("url").and_then(|v| v.as_str()) {
846 Some(u) if !u.is_empty() => {}
847 _ => errors.push(format!(
848 "{entry_path}.source.url: url source must declare a non-empty 'url' string"
849 )),
850 },
851 // `registry` type is reserved for future use but not
852 // accepted yet — fails here alongside any other unknown.
853 Some(other) => errors.push(format!(
854 "{entry_path}.source.type: unknown source type '{other}' \
855 (expected 'local' or 'url')"
856 )),
857 None => errors.push(format!(
858 "{entry_path}.source.type: source must declare a 'type' \
859 ('local' or 'url')"
860 )),
861 }
862 }
863 }
864
865 // 5. `belongsTo` is now a tombstone (see `LEGACY_TOMBSTONE_KEYS`).
866 // Cross-mem authorization moved to the workspace-level
867 // `[cross_mem_links]` section in `.memstead/workspace.toml`. Per-mem config
868 // blobs that still carry the field are rejected with the
869 // tombstone error above; no shape validation runs here.
870
871 // 7. Unknown key warnings. Tombstone keys are rejected above and
872 // skipped here so callers don't see a redundant warning alongside
873 // the hard error.
874 for key in obj.keys() {
875 if KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str())
876 || LEGACY_TOMBSTONE_KEYS
877 .iter()
878 .any(|(k, _)| *k == key.as_str())
879 {
880 continue;
881 }
882 warnings.push(format!(
883 "Unknown config key '{key}' \u{2014} will be ignored"
884 ));
885 }
886
887 let error_code = if legacy_field_hit {
888 Some("LEGACY_FIELD_PRESENT".to_string())
889 } else {
890 None
891 };
892
893 ConfigCheckResult {
894 valid: errors.is_empty(),
895 errors,
896 warnings,
897 error_code,
898 }
899}
900
901// ---------------------------------------------------------------------------
902// Config loading
903// ---------------------------------------------------------------------------
904
905/// Load and parse a config from a mem directory.
906/// Reads `<mem_dir>/.memstead/config.json`.
907pub fn load_config(mem_dir: &Path) -> Result<(Value, PathBuf), ConfigError> {
908 let config_path = mem_dir.join(MEM_META_DIR).join("config.json");
909 let raw = std::fs::read_to_string(&config_path).map_err(|e| {
910 if e.kind() == std::io::ErrorKind::NotFound {
911 ConfigError::NotFound(config_path.display().to_string())
912 } else {
913 ConfigError::Io(e)
914 }
915 })?;
916 let parsed: Value = serde_json::from_str(&raw)
917 .map_err(|_| ConfigError::InvalidJson(config_path.display().to_string()))?;
918 Ok((parsed, config_path))
919}
920
921/// Parse a raw JSON value into a MemConfig.
922pub fn parse_mem_config(value: &Value) -> Result<MemConfig, ConfigError> {
923 serde_json::from_value(value.clone()).map_err(|e| ConfigError::Other(e.to_string()))
924}
925
926/// Load, validate, and parse a mem config from disk.
927pub fn load_and_validate(mem_dir: &Path) -> Result<MemConfig, ConfigError> {
928 let (raw, _path) = load_config(mem_dir)?;
929
930 let result = check_config(&raw);
931 if !result.valid {
932 return Err(ConfigError::ValidationFailed(result.errors));
933 }
934
935 parse_mem_config(&raw)
936}
937
938// ---------------------------------------------------------------------------
939// Config writing
940// ---------------------------------------------------------------------------
941
942/// Write a config JSON value to disk (pretty-printed with trailing newline).
943fn write_config(config_path: &Path, config: &Value) -> Result<(), ConfigError> {
944 let json = serde_json::to_string_pretty(config)? + "\n";
945 std::fs::write(config_path, json)?;
946 Ok(())
947}
948
949/// Validate and write a config to disk. Returns check result.
950fn commit_config(
951 config_path: &Path,
952 config: &Value,
953 dry_run: bool,
954) -> Result<ConfigCheckResult, ConfigError> {
955 let check = check_config(config);
956 if !check.valid {
957 return Ok(check);
958 }
959 if !dry_run {
960 write_config(config_path, config)?;
961 }
962 Ok(check)
963}
964
965// ---------------------------------------------------------------------------
966// Config CRUD operations
967// ---------------------------------------------------------------------------
968
969/// Allowed top-level fields for `update_config_field`. The
970/// workspace rewrite dropped `mediums` and
971/// `projections` here: the engine no longer recognises those blocks so
972/// they are not writable through the update surface either.
973const ALLOWED_UPDATE_FIELDS: &[&str] = &[
974 "version",
975 "description",
976 "authors",
977 "writeGuidance",
978 "rules",
979 "readMems",
980 "schema",
981 "language",
982 "publish",
983];
984
985const PROTECTED_FIELDS: &[&str] = &["name"];
986
987/// Update a top-level config field.
988pub fn update_config_field(
989 config_path: &Path,
990 config: &mut Value,
991 field: &str,
992 value: Value,
993 dry_run: bool,
994) -> Result<ConfigCheckResult, ConfigError> {
995 if PROTECTED_FIELDS.contains(&field) {
996 return Err(ConfigError::Other(format!("Field '{field}' is protected")));
997 }
998
999 let obj = config
1000 .as_object_mut()
1001 .ok_or_else(|| ConfigError::Other("config must be an object".into()))?;
1002
1003 if !ALLOWED_UPDATE_FIELDS.contains(&field) {
1004 return Err(ConfigError::Other(format!(
1005 "Field '{field}' is not a recognized config field. Allowed: {}",
1006 ALLOWED_UPDATE_FIELDS.join(", ")
1007 )));
1008 }
1009
1010 obj.insert(field.to_string(), value);
1011 commit_config(config_path, config, dry_run)
1012}
1013
1014// ===========================================================================
1015// Tests
1016// ===========================================================================
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use serde_json::json;
1022
1023 // --- check_config tests ---
1024
1025 fn minimal_valid_config() -> Value {
1026 json!({
1027 "schema": "default@1.0.0"
1028 })
1029 }
1030
1031 #[test]
1032 fn check_valid_minimal_config() {
1033 let result = check_config(&minimal_valid_config());
1034 assert!(result.valid, "errors: {:?}", result.errors);
1035 }
1036
1037 /// The in-config `name` field is optional — configs without a
1038 /// `name` key are valid; the leaf folder name under
1039 /// `__MEMSTEAD:mems/` (or the disk basename on the legacy disk
1040 /// path) is the authoritative identifier instead.
1041 #[test]
1042 fn check_missing_name_now_valid() {
1043 let config = json!({"schema": "default@1.0.0"});
1044 let result = check_config(&config);
1045 assert!(result.valid, "errors: {:?}", result.errors);
1046 }
1047
1048 /// `parse_mem_config` produces a `MemConfig` whose `name` is
1049 /// `None` when the on-disk config omits the field. Pins the
1050 /// Goal 3 wire-shape contract.
1051 #[test]
1052 fn parse_mem_config_name_none_when_field_absent() {
1053 let config = json!({"schema": "default@1.0.0"});
1054 let parsed = parse_mem_config(&config).expect("name-less config parses");
1055 assert!(parsed.name.is_none());
1056 }
1057
1058 /// Round-trip: a `MemConfig` whose `name` is `None` serialises
1059 /// without the `name` key (skip-if-none on the serde attribute).
1060 /// Pins the on-disk minimisation contract.
1061 #[test]
1062 fn mem_config_omits_name_when_none_on_serialize() {
1063 let cfg = MemConfig {
1064 name: None,
1065 title: None,
1066 subject: None,
1067 version: None,
1068 description: None,
1069 authors: None,
1070 process_mem: None,
1071 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
1072 write_guidance: Default::default(),
1073 rules: None,
1074 publish: None,
1075 language: None,
1076 read_mems: Default::default(),
1077 community: None,
1078 vcs: None,
1079 unregistered_at: None,
1080 sync_state: Default::default(),
1081 review_mark: None,
1082 mutation_stamp: None,
1083 extra: Default::default(),
1084 };
1085 let json = serde_json::to_string(&cfg).unwrap();
1086 assert!(
1087 !json.contains("\"name\""),
1088 "serialized config must omit `name` when None, got: {json}"
1089 );
1090 }
1091
1092 /// A stray `name` field is rejected with `LEGACY_FIELD_PRESENT`
1093 /// regardless of its value (empty or non-empty). Both empty and
1094 /// non-empty shapes collapse onto the legacy tombstone reject.
1095 #[test]
1096 fn check_legacy_name_field_rejected() {
1097 for value in [json!(""), json!("@test/mem")] {
1098 let config = json!({"name": value, "schema": "default@1.0.0"});
1099 let result = check_config(&config);
1100 assert!(!result.valid, "name={value}: expected reject");
1101 assert_eq!(
1102 result.error_code.as_deref(),
1103 Some("LEGACY_FIELD_PRESENT"),
1104 "name={value}: expected LEGACY_FIELD_PRESENT envelope"
1105 );
1106 assert!(
1107 result.errors.iter().any(|e| e.contains("Legacy `name`")),
1108 "name={value}: errors {:?}",
1109 result.errors
1110 );
1111 }
1112 }
1113
1114 #[test]
1115 fn check_missing_schema() {
1116 let config = json!({});
1117 let result = check_config(&config);
1118 assert!(!result.valid);
1119 assert!(result.errors.iter().any(|e| e.contains("`schema`")));
1120 }
1121
1122 #[test]
1123 fn check_legacy_types_array_rejected() {
1124 let config = json!({"types": ["spec"], "schema": "default@1.0.0"});
1125 let result = check_config(&config);
1126 assert!(!result.valid);
1127 assert!(result.errors.iter().any(|e| e.contains("Legacy `types:")));
1128 assert_eq!(
1129 result.error_code.as_deref(),
1130 Some("LEGACY_FIELD_PRESENT"),
1131 "expected LEGACY_FIELD_PRESENT envelope for legacy `types`"
1132 );
1133 }
1134
1135 #[test]
1136 fn check_schema_wrong_shape() {
1137 let config = json!({"schema": ["default@1.0.0"]});
1138 let result = check_config(&config);
1139 assert!(!result.valid);
1140 }
1141
1142 #[test]
1143 fn check_schema_bare_name_rejected() {
1144 // Bare-name pins are rejected at load — every mem config must
1145 // declare an exact `<name>@<version>` pin so cross-mem link
1146 // matching and archive identity are unambiguous.
1147 let config = json!({"schema": "default"});
1148 let result = check_config(&config);
1149 assert!(!result.valid, "expected bare-name pin to be rejected");
1150 assert!(
1151 result.errors.iter().any(|e| e.contains("schema")),
1152 "errors: {:?}",
1153 result.errors
1154 );
1155 }
1156
1157 #[test]
1158 fn check_schema_range_syntax_rejected() {
1159 for s in [
1160 "default@^1.0.0",
1161 "default@~1.0.0",
1162 "default@latest",
1163 "default@>=1.0.0",
1164 ] {
1165 let config = json!({"schema": s});
1166 let result = check_config(&config);
1167 assert!(!result.valid, "expected '{s}' to be rejected");
1168 }
1169 }
1170
1171 #[test]
1172 fn check_schema_valid_exact_pin() {
1173 let config = json!({"schema": "default@1.0.0"});
1174 let result = check_config(&config);
1175 assert!(result.valid, "errors: {:?}", result.errors);
1176 }
1177
1178 #[test]
1179 fn schema_pin_versioned_parses() {
1180 let pin: SchemaRef = "software@1.2.3".parse().unwrap();
1181 assert_eq!(pin.name, "software");
1182 assert_eq!(pin.version, semver::Version::new(1, 2, 3));
1183 assert_eq!(pin.as_display(), "software@1.2.3");
1184 }
1185
1186 #[test]
1187 fn schema_pin_bare_name_rejected() {
1188 // Bare-name pins are rejected at parse — agents must declare the
1189 // exact version. Bogus name shapes (uppercase, slash, empty) fall
1190 // through the same gate.
1191 for bad in ["software", "Default", "foo/bar", "", " "] {
1192 assert!(
1193 bad.parse::<SchemaRef>().is_err(),
1194 "expected '{bad}' to be rejected"
1195 );
1196 }
1197 }
1198
1199 #[test]
1200 fn schema_pin_serde_round_trip() {
1201 let versioned: SchemaRef = serde_json::from_str(r#""software@1.0.0""#).unwrap();
1202 assert_eq!(versioned.as_display(), "software@1.0.0");
1203 let as_json = serde_json::to_string(&versioned).unwrap();
1204 assert_eq!(as_json, r#""software@1.0.0""#);
1205 }
1206
1207 #[test]
1208 fn publish_rejects_missing_schema() {
1209 // Archives record a concrete schema version; a config without a
1210 // `schema` field cannot be published.
1211 let json = json!({ "version": "1.0.0" });
1212 let config = parse_mem_config(&json).unwrap();
1213 let err = published_config_from(&config, "demo").unwrap_err();
1214 assert!(matches!(err, PublishConversionError::MissingSchema));
1215 }
1216
1217 #[test]
1218 fn publish_accepts_versioned_pin() {
1219 let json = json!({
1220 "version": "1.0.0",
1221 "schema": "software@2.3.4"
1222 });
1223 let config = parse_mem_config(&json).unwrap();
1224 let published = published_config_from(&config, "demo").expect("versioned pin publishes");
1225 assert_eq!(published.name, "demo");
1226 assert_eq!(published.schema.name, "software");
1227 assert_eq!(published.schema.version, semver::Version::new(2, 3, 4));
1228 }
1229
1230 #[test]
1231 fn check_legacy_default_schema_field_is_ignored() {
1232 // `defaultSchema` was an author-only tombstone field pre-2026-04.
1233 // It's captured into `extra` and surfaces as an unknown-field
1234 // warning without invalidating an otherwise well-formed config.
1235 let config = json!({
1236 "schema": "default@1.0.0",
1237 "defaultSchema": "spec"
1238 });
1239 let result = check_config(&config);
1240 assert!(result.valid, "errors: {:?}", result.errors);
1241 }
1242
1243 #[test]
1244 fn config_preserves_unknown_fields_on_roundtrip() {
1245 // Any unknown top-level field (legacy `defaultSchema`, future
1246 // fields, typos) is captured into `extra` and re-emitted
1247 // unchanged — guarantees no silent data loss on read-modify-write.
1248 let raw = json!({
1249 "schema": "default@1.0.0",
1250 "defaultSchema": "concept"
1251 });
1252 let cfg: MemConfig = serde_json::from_value(raw).expect("config deserialized");
1253 assert!(
1254 cfg.extra.contains_key("defaultSchema"),
1255 "legacy field should be preserved in extra: {:?}",
1256 cfg.extra
1257 );
1258
1259 let reserialized = serde_json::to_value(&cfg).expect("config reserialized");
1260 assert_eq!(
1261 reserialized.get("defaultSchema").and_then(|v| v.as_str()),
1262 Some("concept"),
1263 "round-trip should preserve the legacy field"
1264 );
1265 }
1266
1267 #[test]
1268 fn check_unknown_keys_warned() {
1269 let config = json!({
1270 "schema": "default@1.0.0",
1271 "unknownKey": "value"
1272 });
1273 let result = check_config(&config);
1274 assert!(result.valid);
1275 assert!(result.warnings.iter().any(|w| w.contains("unknownKey")));
1276 }
1277
1278 /// Tombstone keys produce a hard error, not a soft "unknown key"
1279 /// warning. The unknown-key sweep must skip them so callers see
1280 /// exactly one signal per legacy key.
1281 #[test]
1282 fn legacy_tombstone_does_not_double_warn() {
1283 let config = json!({ "name": "x", "schema": "default@1.0.0" });
1284 let result = check_config(&config);
1285 assert!(!result.valid);
1286 let unknown_warning = result
1287 .warnings
1288 .iter()
1289 .any(|w| w.contains("Unknown config key 'name'"));
1290 assert!(
1291 !unknown_warning,
1292 "legacy tombstone must not also surface as unknown-key warning: {:?}",
1293 result.warnings
1294 );
1295 }
1296
1297 // --- MemConfig slimdown ---
1298
1299 #[test]
1300 fn slim_config_with_only_retained_core_fields_loads() {
1301 // The post-slimdown engine reads a minimal config carrying only
1302 // the fields it actually uses. `schema`, `vcs`, `writeGuidance`
1303 // are the intent-level retained set; serde-required collection
1304 // defaults fill the rest. The mem leaf identity is path-derived
1305 // (Goal 3 of mem-repo-restructure) so the in-config `name`
1306 // field is now a tombstone (Goal 10). Cross-mem authorization
1307 // moved to `.memstead/workspace.toml`'s `[cross_mem_links]` section.
1308 let raw = json!({
1309 "schema": "default@1.0.0",
1310 "writeGuidance": {
1311 "style": "structured",
1312 "audience": "agent"
1313 },
1314 "vcs": { "gitdir": ".git", "worktree": "." }
1315 });
1316 let check = check_config(&raw);
1317 assert!(check.valid, "errors: {:?}", check.errors);
1318 let parsed = parse_mem_config(&raw).expect("slim config parses");
1319 assert!(parsed.name.is_none());
1320 assert_eq!(parsed.write_guidance.len(), 2);
1321 assert_eq!(
1322 parsed.write_guidance.get("style").and_then(|v| v.as_str()),
1323 Some("structured")
1324 );
1325 assert!(parsed.vcs.is_some());
1326 assert!(parsed.extra.is_empty());
1327 }
1328
1329 #[test]
1330 fn legacy_projections_block_lands_in_extra_without_error() {
1331 // Pre-rewrite configs carrying `projections` / `mediums` blocks
1332 // are no longer interpreted by the engine, but round-tripping
1333 // them must not fail — the blocks fall into `MemConfig.extra`
1334 // so read-modify-write preserves authorship. check_config emits
1335 // a "Unknown config key" warning per unrecognised top-level key.
1336 let raw = json!({
1337 "schema": "default@1.0.0",
1338 "mediums": {
1339 "codebase": {
1340 "type": "codebase",
1341 "scope": { "tree": [{ "path": "src/", "mode": "allow" }] }
1342 }
1343 },
1344 "projections": {
1345 "p1": {
1346 "intent": "test",
1347 "sources": [{ "medium_ref": "codebase" }],
1348 "destination": { "medium_ref": "graph" }
1349 }
1350 }
1351 });
1352 let check = check_config(&raw);
1353 assert!(
1354 check.valid,
1355 "legacy projections/mediums must load without errors: {:?}",
1356 check.errors
1357 );
1358 let projection_warned = check.warnings.iter().any(|w| w.contains("projections"));
1359 let mediums_warned = check.warnings.iter().any(|w| w.contains("mediums"));
1360 assert!(
1361 projection_warned && mediums_warned,
1362 "unknown-key warnings expected for projections and mediums: {:?}",
1363 check.warnings
1364 );
1365
1366 let parsed = parse_mem_config(&raw).expect("legacy config parses");
1367 assert!(
1368 parsed.extra.contains_key("projections"),
1369 "legacy `projections` must land in extra: {:?}",
1370 parsed.extra.keys().collect::<Vec<_>>()
1371 );
1372 assert!(
1373 parsed.extra.contains_key("mediums"),
1374 "legacy `mediums` must land in extra: {:?}",
1375 parsed.extra.keys().collect::<Vec<_>>()
1376 );
1377 }
1378
1379 #[test]
1380 fn write_guidance_round_trips_as_string_map() {
1381 // `writeGuidance` is an opaque `HashMap<String, Value>` now.
1382 // Round-trip a map with string / array / object / number values
1383 // to confirm every JSON shape survives verbatim — the engine
1384 // must not interpret or normalise its contents.
1385 let raw = json!({
1386 "schema": "default@1.0.0",
1387 "writeGuidance": {
1388 "style": "structured",
1389 "patterns": ["extract", "summarise"],
1390 "nested": { "depth": 2, "flag": true },
1391 "count": 42
1392 }
1393 });
1394 let parsed = parse_mem_config(&raw).expect("config parses");
1395 assert_eq!(parsed.write_guidance.len(), 4);
1396 assert_eq!(
1397 parsed.write_guidance.get("style").and_then(|v| v.as_str()),
1398 Some("structured")
1399 );
1400 let wire = serde_json::to_value(&parsed).expect("reserialize");
1401 let guidance = wire
1402 .get("writeGuidance")
1403 .and_then(|v| v.as_object())
1404 .expect("writeGuidance present in wire form");
1405 assert_eq!(guidance.len(), 4);
1406 assert_eq!(
1407 guidance.get("style").and_then(|v| v.as_str()),
1408 Some("structured")
1409 );
1410 assert_eq!(
1411 guidance
1412 .get("patterns")
1413 .and_then(|v| v.as_array())
1414 .map(|a| a.len()),
1415 Some(2)
1416 );
1417 assert_eq!(
1418 guidance
1419 .get("nested")
1420 .and_then(|v| v.get("depth"))
1421 .and_then(|v| v.as_u64()),
1422 Some(2)
1423 );
1424 }
1425
1426 #[test]
1427 fn write_guidance_empty_map_omits_from_wire() {
1428 // `skip_serializing_if = "HashMap::is_empty"` keeps an unset
1429 // writeGuidance off the wire entirely so existing minimal
1430 // configs don't gain an empty `{}` after a round-trip.
1431 let parsed: MemConfig = serde_json::from_value(minimal_valid_config()).unwrap();
1432 assert!(parsed.write_guidance.is_empty());
1433 let wire = serde_json::to_value(&parsed).unwrap();
1434 assert!(
1435 wire.get("writeGuidance").is_none(),
1436 "empty writeGuidance must be omitted from the wire: {wire}"
1437 );
1438 }
1439
1440 #[test]
1441 fn published_config_strips_extra_and_write_guidance() {
1442 // `PublishedMemConfig` uses `deny_unknown_fields` with a
1443 // fixed whitelist — the catchall `extra` and the pass-through
1444 // `writeGuidance` both fall off the projection. This guards
1445 // against a future reviewer adding either to the whitelist by
1446 // mistake.
1447 let mut extra = HashMap::new();
1448 extra.insert(
1449 "projections".to_string(),
1450 json!({ "p1": { "intent": "x" } }),
1451 );
1452 let mut guidance = HashMap::new();
1453 guidance.insert("style".to_string(), json!("structured"));
1454 let mut sync_state = BTreeMap::new();
1455 sync_state.insert(
1456 "engine-graph/source-files".to_string(),
1457 "deadbeef".to_string(),
1458 );
1459 let cfg = MemConfig {
1460 name: Some("demo".to_string()),
1461 title: None,
1462 subject: None,
1463 version: Some(semver::Version::new(0, 1, 0)),
1464 description: None,
1465 authors: None,
1466 process_mem: None,
1467 schema: Some("default@1.0.0".parse().unwrap()),
1468 write_guidance: guidance,
1469 rules: None,
1470 publish: None,
1471 language: None,
1472 read_mems: BTreeMap::new(),
1473 community: None,
1474 vcs: None,
1475 unregistered_at: None,
1476 sync_state,
1477 review_mark: None,
1478 mutation_stamp: None,
1479 extra,
1480 };
1481 let published = published_config_from(&cfg, "").expect("publish projection");
1482 let wire = serde_json::to_value(&published).expect("serialize");
1483 assert!(
1484 wire.get("projections").is_none(),
1485 "extra must not leak into published wire: {wire}"
1486 );
1487 assert!(
1488 wire.get("writeGuidance").is_none(),
1489 "writeGuidance must not leak into published wire: {wire}"
1490 );
1491 assert!(
1492 wire.get("syncState").is_none(),
1493 "syncState must not leak into published wire: {wire}"
1494 );
1495 }
1496
1497 // --- legacy tombstones — kept to lock behaviour after slimdown ---
1498
1499 // --- migration tests ---
1500
1501 // --- flatten tests ---
1502
1503 // --- shadow detection tests ---
1504
1505 // --- CRUD dry run tests ---
1506
1507 #[test]
1508 fn update_config_field_protected() {
1509 let tmp = tempfile::tempdir().unwrap();
1510 let config_path = tmp.path().join("config.json");
1511
1512 let mut config = minimal_valid_config();
1513 let err =
1514 update_config_field(&config_path, &mut config, "name", json!("new"), true).unwrap_err();
1515 assert!(err.to_string().contains("protected"));
1516 }
1517
1518 #[test]
1519 fn update_config_field_unknown() {
1520 let tmp = tempfile::tempdir().unwrap();
1521 let config_path = tmp.path().join("config.json");
1522
1523 let mut config = minimal_valid_config();
1524 let err = update_config_field(&config_path, &mut config, "banana", json!("yellow"), true)
1525 .unwrap_err();
1526 assert!(err.to_string().contains("not a recognized"));
1527 }
1528
1529 #[test]
1530 fn update_config_field_allowed() {
1531 let tmp = tempfile::tempdir().unwrap();
1532 let config_path = tmp.path().join("config.json");
1533
1534 let mut config = minimal_valid_config();
1535 let result =
1536 update_config_field(&config_path, &mut config, "language", json!("en"), true).unwrap();
1537 assert!(result.valid, "errors: {:?}", result.errors);
1538 assert_eq!(config["language"], "en");
1539 }
1540
1541 // --- is_encompassed_by tests ---
1542
1543 // --- Graph medium scope validation ---
1544
1545 // --- version parsing (semver) ---
1546
1547 #[test]
1548 fn parse_accepts_valid_semver_version() {
1549 let cfg = json!({
1550 "schema": "default@1.0.0",
1551 "version": "1.2.3-beta.4"
1552 });
1553 let parsed = parse_mem_config(&cfg).expect("valid semver should parse");
1554 let v = parsed.version.expect("version present");
1555 assert_eq!(v.major, 1);
1556 assert_eq!(v.minor, 2);
1557 assert_eq!(v.patch, 3);
1558 assert!(!v.pre.is_empty());
1559 }
1560
1561 #[test]
1562 fn parse_rejects_invalid_semver_version() {
1563 // "1.2" is not valid semver — must be MAJOR.MINOR.PATCH.
1564 let cfg = json!({
1565 "schema": "default@1.0.0",
1566 "version": "1.2"
1567 });
1568 let err = parse_mem_config(&cfg).expect_err("invalid semver must fail at parse");
1569 let msg = format!("{err}");
1570 assert!(
1571 msg.contains("version"),
1572 "error should mention version: {msg}"
1573 );
1574 }
1575
1576 #[test]
1577 fn parse_rejects_non_semver_garbage_version() {
1578 let cfg = json!({
1579 "schema": "default@1.0.0",
1580 "version": "potato"
1581 });
1582 let err = parse_mem_config(&cfg).expect_err("garbage must fail at parse");
1583 let msg = format!("{err}");
1584 assert!(
1585 msg.contains("version"),
1586 "error should mention version: {msg}"
1587 );
1588 }
1589
1590 // --- readMems: `{ source: { type, … } }` entries — no path or
1591 // version fields (the cached archive's config is authoritative) ---
1592
1593 #[test]
1594 fn parse_accepts_read_mems_with_local_source() {
1595 let cfg = json!({
1596 "schema": "default@1.0.0",
1597 "readMems": {
1598 "internal-notes": { "source": { "type": "local" } }
1599 }
1600 });
1601 let check = check_config(&cfg);
1602 assert!(check.valid, "errors: {:?}", check.errors);
1603 let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
1604 let spec = parsed
1605 .read_mems
1606 .get("internal-notes")
1607 .expect("entry present");
1608 assert!(matches!(spec.source, ReadMemSource::Local));
1609 }
1610
1611 #[test]
1612 fn parse_accepts_read_mems_with_url_source() {
1613 let cfg = json!({
1614 "schema": "default@1.0.0",
1615 "readMems": {
1616 "aws-patterns": {
1617 "source": {
1618 "type": "url",
1619 "url": "https://example.com/aws-patterns.mem"
1620 }
1621 }
1622 }
1623 });
1624 let check = check_config(&cfg);
1625 assert!(check.valid, "errors: {:?}", check.errors);
1626 let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
1627 let spec = parsed.read_mems.get("aws-patterns").expect("entry present");
1628 match &spec.source {
1629 ReadMemSource::Url { url } => {
1630 assert_eq!(url, "https://example.com/aws-patterns.mem")
1631 }
1632 _ => panic!("expected Url source, got {:?}", spec.source),
1633 }
1634 }
1635
1636 #[test]
1637 fn parse_accepts_empty_read_mems_map() {
1638 let cfg = json!({
1639 "schema": "default@1.0.0",
1640 "readMems": {}
1641 });
1642 let parsed = parse_mem_config(&cfg).expect("empty readMems must parse");
1643 assert!(parsed.read_mems.is_empty());
1644 }
1645
1646 #[test]
1647 fn parse_accepts_omitted_read_mems() {
1648 let cfg = json!({
1649 "schema": "default@1.0.0"
1650 });
1651 let parsed = parse_mem_config(&cfg).expect("omitted readMems must parse");
1652 assert!(parsed.read_mems.is_empty());
1653 }
1654
1655 #[test]
1656 fn check_rejects_read_mem_without_source() {
1657 let cfg = json!({
1658 "schema": "default@1.0.0",
1659 "readMems": { "p": {} }
1660 });
1661 let check = check_config(&cfg);
1662 assert!(!check.valid);
1663 assert!(
1664 check.errors.iter().any(|e| e.contains("source")),
1665 "errors: {:?}",
1666 check.errors
1667 );
1668 }
1669
1670 #[test]
1671 fn check_rejects_read_mem_with_unknown_source_type() {
1672 let cfg = json!({
1673 "schema": "default@1.0.0",
1674 "readMems": {
1675 "p": { "source": { "type": "ftp", "url": "ftp://..." } }
1676 }
1677 });
1678 let check = check_config(&cfg);
1679 assert!(!check.valid);
1680 assert!(
1681 check
1682 .errors
1683 .iter()
1684 .any(|e| e.contains("unknown source type")),
1685 "errors: {:?}",
1686 check.errors
1687 );
1688 }
1689
1690 #[test]
1691 fn check_rejects_url_source_with_empty_url() {
1692 let cfg = json!({
1693 "schema": "default@1.0.0",
1694 "readMems": {
1695 "p": { "source": { "type": "url", "url": "" } }
1696 }
1697 });
1698 let check = check_config(&cfg);
1699 assert!(!check.valid);
1700 assert!(
1701 check.errors.iter().any(|e| e.contains("url source")),
1702 "errors: {:?}",
1703 check.errors
1704 );
1705 }
1706
1707 #[test]
1708 fn check_rejects_registry_source_type_reserved_for_phase_d() {
1709 let cfg = json!({
1710 "schema": "default@1.0.0",
1711 "readMems": {
1712 "p": { "source": { "type": "registry" } }
1713 }
1714 });
1715 let check = check_config(&cfg);
1716 // `registry` is a reserved future source type but is not yet
1717 // accepted by the schema — validation must reject it until the
1718 // registry ships.
1719 assert!(!check.valid);
1720 assert!(
1721 check
1722 .errors
1723 .iter()
1724 .any(|e| e.contains("unknown source type")),
1725 "errors: {:?}",
1726 check.errors
1727 );
1728 }
1729
1730 /// Guards the `BTreeMap` choice: serialized read_mems must come out
1731 /// in key-sorted order regardless of insertion order, so config files
1732 /// on disk and log output are reproducible. A future "optimisation"
1733 /// that reintroduces `HashMap` would break this.
1734 #[test]
1735 fn read_mems_serialization_order_is_key_sorted() {
1736 let cfg = json!({
1737 "schema": "default@1.0.0",
1738 "readMems": {
1739 "zebra": { "source": { "type": "local" } },
1740 "alpha": { "source": { "type": "local" } },
1741 "mango": { "source": { "type": "local" } }
1742 }
1743 });
1744 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1745 let reserialized = serde_json::to_string(&parsed).expect("serialization must succeed");
1746 let alpha = reserialized.find("alpha").expect("alpha present");
1747 let mango = reserialized.find("mango").expect("mango present");
1748 let zebra = reserialized.find("zebra").expect("zebra present");
1749 assert!(
1750 alpha < mango && mango < zebra,
1751 "expected alpha < mango < zebra, got: {reserialized}"
1752 );
1753 }
1754
1755 // ----- vcs field -----
1756
1757 #[test]
1758 fn vcs_config_round_trips_through_serde_with_both_fields() {
1759 let cfg = json!({
1760 "schema": "default@1.0.0",
1761 "vcs": { "gitdir": "../.git", "worktree": ".." }
1762 });
1763 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1764 let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
1765 assert_eq!(vcs.gitdir, "../.git");
1766 assert_eq!(vcs.worktree, "..");
1767
1768 // Round-trip.
1769 let reserialized = serde_json::to_value(&parsed).unwrap();
1770 let round = parse_mem_config(&reserialized).expect("round-trip parse");
1771 assert_eq!(round.vcs.as_ref().unwrap().gitdir, "../.git");
1772 assert_eq!(round.vcs.as_ref().unwrap().worktree, "..");
1773 }
1774
1775 #[test]
1776 fn vcs_config_worktree_defaults_to_dot_when_omitted() {
1777 let cfg = json!({
1778 "schema": "default@1.0.0",
1779 "vcs": { "gitdir": ".git" }
1780 });
1781 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1782 let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
1783 assert_eq!(vcs.gitdir, ".git");
1784 assert_eq!(vcs.worktree, ".", "worktree must default to \".\"");
1785 }
1786
1787 #[test]
1788 fn vcs_config_absent_is_none() {
1789 let cfg = json!({ "schema": "default@1.0.0" });
1790 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1791 assert!(parsed.vcs.is_none(), "missing vcs must deserialize to None");
1792 }
1793
1794 #[test]
1795 fn vcs_field_tolerates_legacy_string_value() {
1796 // Legacy macOS-app sentinel. The tolerant deserializer must keep
1797 // the config loadable (returning None) without touching the
1798 // user's file by hand.
1799 let cfg = json!({
1800 "schema": "default@1.0.0",
1801 "vcs": "system"
1802 });
1803 let parsed = parse_mem_config(&cfg).expect("legacy vcs string must parse");
1804 assert!(
1805 parsed.vcs.is_none(),
1806 "legacy string must deserialize to None"
1807 );
1808 }
1809
1810 #[test]
1811 fn published_config_strips_vcs() {
1812 // `published_config_from` must drop the `vcs` block — VCS layout
1813 // is workspace-local mechanics, never part of the published
1814 // mem's identity. This is guaranteed by the whitelist
1815 // projection: `PublishedMemConfig` has no `vcs` field, so a
1816 // MemConfig carrying `vcs: Some(...)` projects to a
1817 // PublishedMemConfig with no `vcs` on the wire.
1818 let mut cfg = MemConfig {
1819 name: Some("demo".to_string()),
1820 title: None,
1821 subject: None,
1822 version: Some(semver::Version::new(0, 1, 0)),
1823 description: None,
1824 authors: None,
1825 process_mem: None,
1826 schema: Some("default@1.0.0".parse().unwrap()),
1827 write_guidance: HashMap::new(),
1828 rules: None,
1829 publish: None,
1830 language: None,
1831 read_mems: BTreeMap::new(),
1832 community: None,
1833 vcs: None,
1834 unregistered_at: None,
1835 sync_state: BTreeMap::new(),
1836 review_mark: None,
1837 mutation_stamp: None,
1838 extra: HashMap::new(),
1839 };
1840 cfg.vcs = Some(VcsConfig {
1841 gitdir: ".git".to_string(),
1842 worktree: ".".to_string(),
1843 });
1844 let published = published_config_from(&cfg, "").expect("valid projection");
1845 let wire = serde_json::to_value(&published).expect("serialize");
1846 assert!(
1847 wire.get("vcs").is_none(),
1848 "published wire form must not carry vcs: got {wire}"
1849 );
1850 }
1851
1852 // ----- belongsTo legacy tombstone -----
1853
1854 /// `belongsTo` is now a tombstone — cross-mem authorization
1855 /// migrated to the workspace-level `[cross_mem_links]` section in
1856 /// `.memstead/workspace.toml`. A per-mem config blob carrying `belongsTo` is
1857 /// rejected with `LEGACY_FIELD_PRESENT`.
1858 #[test]
1859 fn belongs_to_field_is_legacy_tombstone() {
1860 let cfg = json!({
1861 "schema": "default@1.0.0",
1862 "belongsTo": ["main"]
1863 });
1864 let result = check_config(&cfg);
1865 assert!(!result.valid, "belongsTo presence must fail validation");
1866 assert_eq!(result.error_code.as_deref(), Some("LEGACY_FIELD_PRESENT"));
1867 assert!(
1868 result
1869 .errors
1870 .iter()
1871 .any(|e| e.contains("belongsTo") && e.contains("cross_mem_links")),
1872 "tombstone error must name the field and the replacement section: {:?}",
1873 result.errors
1874 );
1875 }
1876}