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