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 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}