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