Skip to main content

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