pub mod agent_notes;
pub mod branch_reset;
pub mod changes;
pub mod commit_envelope;
pub mod diff;
pub mod export;
pub mod health;
pub mod integrity;
#[cfg(not(target_arch = "wasm32"))]
pub mod search;
pub mod transport;
pub use agent_notes::{AgentNotesReport, CommitNote};
pub use branch_reset::{BranchResetOutcome, StrandedCrossMemRef};
pub use changes::{
BackendChanges, ChangeEnvelope, ChangesReport, EMPTY_TREE_SHA, MemChangedNotice,
NoticeByChange, NoticeChanges, RENAME_SIMILARITY_DEFAULT, RENAME_SIMILARITY_MAX,
RENAME_SIMILARITY_MIN, folder_changes_since,
};
pub use commit_envelope::{CommitEnvelope, EntityChange};
pub use diff::{Diff, DiffConfig, EntityDiff, IncomingRipple};
pub use export::{MemExportBytes, MemExportError};
pub use transport::{FetchOutcome, PullOutcome, PushOutcome, RemoteAddOutcome, UpdatedRef};
use crate::entity::EntityId;
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct};
use std::collections::HashMap;
use std::fmt;
pub const OVERVIEW_INCLUDE_KEYS: &[&str] = &[
"community_members",
"community_bridges",
"mem_distribution",
"dangling_links",
];
pub(crate) fn format_types_clause(types: &[String]) -> String {
types
.iter()
.map(|t| format!("'{t}'"))
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn type_word_for(types: &[String]) -> &'static str {
if types.len() == 1 { "type" } else { "types" }
}
#[derive(Debug, Clone)]
pub struct CreateArgs {
pub title: String,
pub mem: String,
pub entity_type: String,
pub sections: IndexMap<String, String>,
pub metadata: IndexMap<String, String>,
pub relations: Vec<RelateArg>,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct UpdateArgs {
pub id: EntityId,
pub expected_hash: String,
pub sections: IndexMap<String, String>,
pub append_sections: IndexMap<String, String>,
pub patch_sections: IndexMap<String, PatchArg>,
pub metadata: IndexMap<String, String>,
pub metadata_unset: Vec<String>,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct PatchArg {
pub old: String,
pub new: String,
pub all: bool,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ModifiedSections {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replaced: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub appended: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patched: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ModifiedMetadata {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub set: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unset: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateResult {
pub id: EntityId,
pub title: String,
pub modified_sections: ModifiedSections,
pub modified_metadata: ModifiedMetadata,
pub modified_date: String,
#[serde(rename = "_hash")]
pub content_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prospective_hash: Option<String>,
#[serde(default)]
pub commit_sha: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, Clone)]
pub enum WarningHint {
MissingRequiredSection {
entity_type: String,
key: String,
heading: String,
write_rules: Vec<String>,
},
MissingRequiredField {
entity_type: String,
key: String,
description: String,
enum_values: Vec<String>,
},
UndeclaredRelationshipOpen { rel_type: String, message: String },
DuplicateRelationship {
rel_type: String,
from: EntityId,
to: EntityId,
},
NoSuchRelationship {
rel_type: String,
from: EntityId,
to: EntityId,
},
UnknownIncludeKey { key: String, allowed: Vec<String> },
LimitClamped { requested: usize, actual: usize },
TitleNormalizedToSlugNoop {
requested_title: String,
current_slug: String,
},
TitleCharsDroppedFromSlug {
title: String,
dropped_chars: Vec<char>,
slug: String,
},
UpdateNoop { id: EntityId },
StubFilterExcludesAll { entity_type: String },
UnknownFilterKey {
key: String,
scoped_type: Option<String>,
declared_on_other_types: Vec<String>,
},
FieldNotFilterable { field: String },
FilterValueMultiMember { key: String, value: String },
FilterValueNotInEnum {
key: String,
value: String,
allowed: Vec<String>,
},
NeighbourhoodCapped { kept: usize, total: usize },
SearchResultsTruncated { kept: usize, budget: usize },
RangeFilterKeyMalformed { key: String },
UnknownRangeFilterField {
field: String,
key: String,
scoped_type: Option<String>,
declared_on_other_types: Vec<String>,
},
FieldNotRangeFilterable { field: String },
SearchMemIndexUnavailable {
mem: String,
reason: &'static str,
error: Option<String>,
},
TitleTrimmed { original: String, trimmed: String },
SuspiciousNestedPrefix {
from: EntityId,
resolved_id: EntityId,
candidate_target: Option<EntityId>,
section: String,
},
InlineWikiLinkAutoStubbed {
from: EntityId,
stubs: Vec<EntityId>,
},
SelfLinkIgnored { id: EntityId },
CrossMemTargetMemUncreated {
from_mem: String,
to_mem: String,
target_id: EntityId,
},
NoteMissing { tool: String },
IgnoredReadonlyField { field: String, supplied: String },
OuterRepoNotIgnoringMemRepo {
outer_repo_root: String,
workspace_root: String,
},
MissingRequiredOutgoing {
entity_type: String,
entity_id: EntityId,
missing: Vec<MissingRequiredOutgoingBlock>,
},
ConstraintUnsatisfied {
entity_type: String,
entity_id: EntityId,
violations: Vec<crate::ops::health::UnsatisfiedConstraint>,
},
DuplicateSectionHeading {
entity_id: EntityId,
section_key: String,
heading: String,
occurrences: usize,
},
MemReloaded {
mem: String,
old_head: String,
new_head: String,
entities_loaded: usize,
},
AutoStubCreated { stub_id: EntityId, pending: bool },
DerivationBaselineRefreshed {
from: EntityId,
rel_type: String,
to: EntityId,
},
ParsedRelationInvalid {
entity_id: EntityId,
rel_type: String,
target: EntityId,
reason: String,
origin: String,
recovery: Option<ParsedRelationRecovery>,
},
ResidualStubForReadOnlyReferrers {
id: EntityId,
referrers: Vec<EntityId>,
},
MemFilesNotDeleted {
mem: String,
reason: String,
path: Option<String>,
error: Option<String>,
},
MemReattachedAfterUnregister {
mem: String,
unregistered_at: String,
},
ReadMemsMigratedToMounts {
mems: Vec<String>,
from_host_mems: Vec<String>,
},
EngineVersionSkew {
mem: String,
stamped_engine: String,
running_engine: String,
stamped_schema: String,
},
SchemaGenerationsBehind {
mem: String,
pinned: String,
newest: String,
},
FolderMemProvenance { mem: String },
SchemaAuthoringSourceMissing {
schema_ref: String,
stamped_path: String,
mems: Vec<String>,
},
SchemaAuthoringSourceDiverged {
schema_ref: String,
stamped_path: String,
mems: Vec<String>,
detail: String,
},
AmbiguousDescriptionDelimiter {
from: EntityId,
rel_type: String,
target: EntityId,
trailing: String,
},
ParseMissingRequiredDescription {
from: EntityId,
rel_type: String,
target: EntityId,
},
ParseDescriptionNotPermitted {
from: EntityId,
rel_type: String,
target: EntityId,
},
SchemaPinMismatch {
mem: String,
config_pin: String,
mount_pin: String,
},
SectionHeadingDivergence {
entity_id: EntityId,
section_key: String,
writing_heading: String,
existing_heading: String,
},
SchemaHeadingRoundtripViolation {
mem: String,
schema_ref: String,
violations: Vec<SchemaHeadingViolation>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SchemaHeadingViolation {
pub type_name: String,
pub key: String,
pub heading: String,
pub derived_key: String,
}
impl From<&memstead_schema::HeadingKeyViolation> for SchemaHeadingViolation {
fn from(v: &memstead_schema::HeadingKeyViolation) -> Self {
Self {
type_name: v.type_name.clone(),
key: v.key.clone(),
heading: v.heading.clone(),
derived_key: v.derived_key.clone(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct MissingRequiredOutgoingBlock {
pub relationships: Vec<String>,
pub cardinality: String,
#[serde(skip_serializing_if = "severity_is_warn")]
pub severity: memstead_schema::ConstraintSeverity,
}
fn severity_is_warn(s: &memstead_schema::ConstraintSeverity) -> bool {
*s == memstead_schema::ConstraintSeverity::Warn
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ParsedRelationRecovery {
pub kind: String,
pub source_id: EntityId,
pub target_id: EntityId,
pub rel_type: String,
}
impl ParsedRelationRecovery {
pub const KIND_REMOVE_EXPLICIT_RELATION: &'static str = "remove_explicit_relation";
pub fn remove_explicit_relation(
source_id: EntityId,
target_id: EntityId,
rel_type: String,
) -> Self {
Self {
kind: Self::KIND_REMOVE_EXPLICIT_RELATION.to_string(),
source_id,
target_id,
rel_type,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ParseRecoveryEntry {
pub entity_id: EntityId,
pub rel_type: String,
pub target: EntityId,
pub outcome: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl ParseRecoveryEntry {
pub const OUTCOME_REMOVED: &'static str = "removed";
pub const OUTCOME_SKIPPED: &'static str = "skipped";
pub const OUTCOME_FAILED: &'static str = "failed";
pub const REASON_READONLY_MOUNT: &'static str = "readonly_mount";
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ParseRecoveryReport {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<ParseRecoveryEntry>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub commit_sha: String,
}
impl fmt::Display for WarningHint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WarningHint::SchemaPinMismatch {
mem,
config_pin,
mount_pin,
} => write!(
f,
"mem '{mem}': the workspace mount expects schema '{mount_pin}' but the \
mem's own config pins '{config_pin}' — the config pin is authoritative and \
was used; align the mounts.json entry or the mem config to clear this"
),
WarningHint::SectionHeadingDivergence {
entity_id,
section_key,
writing_heading,
existing_heading,
} => write!(
f,
"entity '{entity_id}': section '{section_key}' is being written under \
heading '{writing_heading}' but the file carried '{existing_heading}' for \
the same section — the write commits and the regenerated file uses \
'{writing_heading}'; the previous heading text is replaced"
),
WarningHint::SchemaHeadingRoundtripViolation {
mem,
schema_ref,
violations,
} => {
let list = violations
.iter()
.map(|v| {
format!(
"type '{}' section '{}' heading '{}' (derives to '{}')",
v.type_name, v.key, v.heading, v.derived_key
)
})
.collect::<Vec<_>>()
.join("; ");
write!(
f,
"mem '{mem}': pinned schema '{schema_ref}' declares section heading(s) \
that cannot round-trip to their key(s): {list}. The mem keeps loading, \
but writes to these sections fork content into a second heading or the \
catch-all. Fix the schema's heading/key pairs and reinstall — new \
installs of such a schema are refused"
)
}
WarningHint::MissingRequiredSection {
key,
heading,
write_rules,
..
} => {
write!(
f,
"required section '{key}' (heading \"{heading}\") is empty — \
entity will show as unhealthy"
)?;
if !write_rules.is_empty() {
write!(f, ". Writing guidance:")?;
for rule in write_rules {
write!(f, "\n - {rule}")?;
}
}
Ok(())
}
WarningHint::MissingRequiredField {
key,
entity_type,
description,
enum_values,
} => {
write!(
f,
"required metadata field '{key}' on type '{entity_type}' was not \
supplied — entity landed with a placeholder. {description}"
)?;
if !enum_values.is_empty() {
write!(f, " Allowed values: [{}].", enum_values.join(", "))?;
}
Ok(())
}
WarningHint::UndeclaredRelationshipOpen { message, .. } => f.write_str(message),
WarningHint::DuplicateRelationship { rel_type, from, to } => write!(
f,
"relationship {rel_type} from {from} to {to} already exists — no-op"
),
WarningHint::NoSuchRelationship { rel_type, from, to } => write!(
f,
"relationship {rel_type} from {from} to {to} does not exist — no-op"
),
WarningHint::UnknownIncludeKey { key, allowed } => write!(
f,
"unknown include key '{key}' ignored. Allowed: [{}]",
allowed.join(", ")
),
WarningHint::LimitClamped { requested, actual } => write!(
f,
"limit clamped from {requested} to {actual} (max for memstead_health)"
),
WarningHint::TitleNormalizedToSlugNoop {
requested_title,
current_slug,
} => write!(
f,
"requested title '{requested_title}' normalises to the existing slug \
'{current_slug}' — no change written to disk"
),
WarningHint::TitleCharsDroppedFromSlug {
title,
dropped_chars,
slug,
} => write!(
f,
"title '{title}' keeps its characters as display text, but the derived \
slug '{slug}' drops {dropped_chars:?} — link this entity by its slug"
),
WarningHint::UpdateNoop { id } => write!(
f,
"update on {id} produced bytes-identical content — no \
disk write, no commit, content_hash unchanged"
),
WarningHint::StubFilterExcludesAll { entity_type } => write!(
f,
"stub=true combined with entity_type='{entity_type}' excludes every \
stub — stubs carry no entity_type. Drop entity_type to list stubs."
),
WarningHint::UnknownFilterKey {
key,
scoped_type,
declared_on_other_types,
} => {
let on_other = !declared_on_other_types.is_empty();
let scoped_matches_other = matches!(
scoped_type.as_deref(),
Some(t) if declared_on_other_types.iter().any(|o| o == t)
);
if let Some(t) = scoped_type.as_deref() {
if on_other && !scoped_matches_other {
let word = type_word_for(declared_on_other_types);
let items = format_types_clause(declared_on_other_types);
return write!(
f,
"filter '{key}' applied with strict type-exclusion semantics — exists on {word} {items} but the query scoped to type '{t}', where it is not declared. All entities of type '{t}' will be excluded; scope to a declaring type to apply the filter."
);
}
return write!(
f,
"unknown filter key '{key}' for type '{t}' — filter ignored"
);
}
if on_other {
let word = type_word_for(declared_on_other_types);
let items = format_types_clause(declared_on_other_types);
return write!(
f,
"filter '{key}' applied with strict type-exclusion semantics — only entities of {word} {items} will match. Scope explicitly via entity_type=… to suppress this warning."
);
}
write!(
f,
"unknown filter key '{key}' — no reachable schema declares it — filter ignored"
)
}
WarningHint::FieldNotFilterable { field } => {
write!(f, "field '{field}' is not filterable — filter ignored")
}
WarningHint::FilterValueMultiMember { key, value } => write!(
f,
"filter '{key}={value}' targets a csv-array field but the value contains a comma — \
csv fields match a single member, so the full value matches nothing. Filter on one \
member at a time (e.g. `{key}={first}`)",
first = value.split(',').next().map(str::trim).unwrap_or("").trim(),
),
WarningHint::FilterValueNotInEnum {
key,
value,
allowed,
} => write!(
f,
"filter '{key}={value}' is not an allowed value for '{key}' — allowed: [{}]. \
The filter applies as written and matches nothing.",
allowed.join(", ")
),
WarningHint::NeighbourhoodCapped { kept, total } => write!(
f,
"related_to neighbourhood has {total} entities; ranked by proximity and bounded to \
the nearest {kept}. Narrow with `depth` or filters to see fewer, more specific hits."
),
WarningHint::SearchResultsTruncated { kept, budget } => write!(
f,
"results trimmed to the highest-ranked {kept} hits to fit the {budget}-token budget. \
`_total` is the full match count — page the rest with `offset`, narrow the query, \
or raise `token_budget`."
),
WarningHint::RangeFilterKeyMalformed { key } => write!(
f,
"range filter key '{key}' must start with 'min_'/'max_' or end with '_before'/'_after' — filter ignored"
),
WarningHint::UnknownRangeFilterField {
field,
key,
scoped_type,
declared_on_other_types,
} => {
let on_other = !declared_on_other_types.is_empty();
let scoped_matches_other = matches!(
scoped_type.as_deref(),
Some(t) if declared_on_other_types.iter().any(|o| o == t)
);
if let Some(t) = scoped_type.as_deref() {
if on_other && !scoped_matches_other {
let word = type_word_for(declared_on_other_types);
let items = format_types_clause(declared_on_other_types);
return write!(
f,
"range filter field '{field}' (from key '{key}') applied with strict type-exclusion semantics — exists on {word} {items} but the query scoped to type '{t}', where it is not declared. All entities of type '{t}' will be excluded; scope to a declaring type to apply the filter."
);
}
return write!(
f,
"unknown range filter field '{field}' (from key '{key}') for type '{t}' — filter ignored"
);
}
if on_other {
let word = type_word_for(declared_on_other_types);
let items = format_types_clause(declared_on_other_types);
return write!(
f,
"range filter field '{field}' (from key '{key}') applied with strict type-exclusion semantics — only entities of {word} {items} will match. Scope explicitly via entity_type=… to suppress this warning."
);
}
write!(
f,
"unknown range filter field '{field}' (from key '{key}') — no reachable schema declares it — filter ignored"
)
}
WarningHint::FieldNotRangeFilterable { field } => write!(
f,
"field '{field}' is not range-filterable — filter ignored"
),
WarningHint::SearchMemIndexUnavailable { mem, reason, error } => {
match (*reason, error.as_deref()) {
("missing_index", _) => {
write!(f, "mem '{mem}' has no search index — query returns no hits")
}
("query_failed", Some(e)) => {
write!(f, "search index for mem '{mem}' errored: {e}")
}
_ => write!(f, "search index for mem '{mem}' is unavailable ({reason})"),
}
}
WarningHint::TitleTrimmed { original, trimmed } => write!(
f,
"title trimmed of surrounding whitespace: {original:?} → {trimmed:?}"
),
WarningHint::SuspiciousNestedPrefix {
from,
resolved_id,
candidate_target,
section,
} => {
write!(
f,
"wiki-link in {from}#{section} resolves to nested prefix \
{resolved_id} — almost certainly mem-rename drift"
)?;
if let Some(cand) = candidate_target {
write!(f, "; did you mean {cand}?")?;
}
Ok(())
}
WarningHint::InlineWikiLinkAutoStubbed { from, stubs } => {
write!(
f,
"{from} contained {n} inline wiki-link(s) that auto-created stub \
entities — review whether the stubs were intended; if not, \
remove the inline syntax or wrap the example in a fenced/quoted \
form. Auto-stubbed targets:",
n = stubs.len(),
)?;
for s in stubs {
write!(f, "\n - {s}")?;
}
Ok(())
}
WarningHint::SelfLinkIgnored { id } => write!(
f,
"{id} contains a body wiki-link to its own id — the self-referential edge \
was dropped (a self-link carries no navigational value). The entity was \
created/updated normally; remove the `[[{slug}]]` link if it was a mistake",
slug = id.name(),
),
WarningHint::CrossMemTargetMemUncreated {
from_mem,
to_mem,
target_id,
} => write!(
f,
"cross-mem relate from '{from_mem}' to '{target_id}': \
target mem '{to_mem}' is not mounted in the workspace — \
the auto-stub has no schema resolution until the mem is created. \
If '{to_mem}' is a typo, fix the relate; if forward-reference \
is intended, create the mem to promote the stub."
),
WarningHint::NoteMissing { tool } => write!(
f,
"{tool} called without a `note` while \
`[mutations].require_notes = true` — commit landed, \
body carries no provenance line"
),
WarningHint::IgnoredReadonlyField { field, supplied } => write!(
f,
"'{field}' is auto-managed by the engine — the supplied \
value '{supplied}' was discarded and the engine value \
stamped instead"
),
WarningHint::OuterRepoNotIgnoringMemRepo {
outer_repo_root,
workspace_root,
} => write!(
f,
"workspace at '{workspace_root}' is embedded inside the git \
repository at '{outer_repo_root}' but the outer .gitignore \
does not list 'mem-repo/'. Add 'mem-repo/' (or the \
workspace-relative equivalent) to the outer repo's \
.gitignore to keep mem-repo-git out of the outer index."
),
WarningHint::MissingRequiredOutgoing {
entity_type,
entity_id,
missing,
} => {
write!(
f,
"{entity_id} ({entity_type}) is missing required outgoing edges — \
schema declares {n} `required_outgoing` block(s) still unsatisfied:",
n = missing.len(),
)?;
for block in missing {
write!(
f,
"\n - [{}] cardinality={}",
block.relationships.join(", "),
block.cardinality,
)?;
}
Ok(())
}
WarningHint::ConstraintUnsatisfied {
entity_type,
entity_id,
violations,
} => {
write!(
f,
"{entity_id} ({entity_type}) violates {n} declared constraint(s):",
n = violations.len(),
)?;
for v in violations {
write!(f, "\n - {}", v.describe())?;
}
Ok(())
}
WarningHint::DuplicateSectionHeading {
entity_id,
section_key,
heading,
occurrences,
} => write!(
f,
"{entity_id} declared `## {heading}` {occurrences} times — \
section '{section_key}' kept the first occurrence's body \
and dropped the rest. The next read-modify-write will \
collapse the markdown to one heading."
),
WarningHint::MemReloaded {
mem,
old_head,
new_head,
entities_loaded,
} => write!(
f,
"mem '{mem}' was reloaded — on-disk HEAD advanced from \
{old_head} to {new_head} (a sibling writer or out-of-band \
commit landed since the engine last read the mem). \
{entities_loaded} entities reloaded; response carries \
fresh content. Re-derive any conclusions that depended on \
the prior content of this mem before continuing. Call \
`memstead_changes_since since={old_head}` for the per-entity \
diff."
),
WarningHint::AutoStubCreated { stub_id, pending } => {
if *pending {
write!(
f,
"target '{stub_id}' does not exist — a stub would be \
auto-created by the real call. Promote it via \
memstead_create first, or let the real call create \
the stub (adoption preserves the incoming edge)."
)
} else {
write!(
f,
"target '{stub_id}' did not exist — stub auto-created. \
Promote it via memstead_create when authoring the real \
entity (stub adoption preserves the incoming edge)."
)
}
}
WarningHint::DerivationBaselineRefreshed { from, rel_type, to } => write!(
f,
"derivation baseline refreshed: '{from}' -[{rel_type}]-> '{to}' — the edge \
already existed; its baseline now records the target's current content \
hash (reviewed, still holds). Nothing else changed."
),
WarningHint::ParsedRelationInvalid {
entity_id,
rel_type,
target,
reason,
origin,
recovery: _,
} => {
let recovery_msg = if origin == "readonly" {
"Source mem is mounted read-only; the engine cannot \
rewrite the markdown. Either remove the mount \
(`memstead uninstall <mem>`) or accept the dropped \
relation."
} else {
"Fix the source markdown (via memstead_update / \
memstead_relate — `details.recovery` carries the abstract \
action) or adjust the schema."
};
write!(
f,
"parsed relation {rel_type} from {entity_id} to \
{target} was dropped — reason: {reason}, origin: \
{origin}. The entity loaded but the relation does \
not appear in the in-memory graph. {recovery_msg}"
)
}
WarningHint::ResidualStubForReadOnlyReferrers { id, referrers } => write!(
f,
"{id} was deleted from disk but {n} read-only-mount \
referrer(s) still target it; the in-memory entity is \
demoted to a stub at the same id so the surviving \
incoming edges keep a valid target. Surviving referrers: \
[{}]. Either accept the stub or remove the source mount \
(`memstead uninstall <mem>`) — read-only content cannot \
be rewritten by the engine.",
referrers
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(", "),
n = referrers.len(),
),
WarningHint::AmbiguousDescriptionDelimiter {
from,
rel_type,
target,
trailing,
} => write!(
f,
"{from} → {target} ({rel_type}): trailing content {trailing:?} \
after `]]` did not match the canonical em-dash delimiter ` — ` \
(U+2014); content dropped, the relation parses with no \
description. Restore with `memstead_relate {from} {rel_type} \
{target} --description \"<text>\"` (or hand-edit using \
` — `) if the text was intentional."
),
WarningHint::ParseMissingRequiredDescription {
from,
rel_type,
target,
} => write!(
f,
"{from} → {target} ({rel_type}): rel-type declares \
`per_edge_description: required` but the row has no \
trailing em-dash description. Add one via `memstead_relate \
{from} {rel_type} {target} --description \"<text>\"` (or \
hand-edit the markdown using ` — `)."
),
WarningHint::ParseDescriptionNotPermitted {
from,
rel_type,
target,
} => write!(
f,
"{from} → {target} ({rel_type}): rel-type declares \
`per_edge_description: forbidden` but the markdown row \
carries a trailing description. The description is \
dropped from the in-memory graph and the next render \
normalises the row to the simple form. Drop the trailing \
text from the source markdown if it should not round-trip."
),
WarningHint::MemReattachedAfterUnregister {
mem,
unregistered_at,
} => write!(
f,
"mem '{mem}' was reattached to pre-existing storage \
that carried an `unregistered_at: {unregistered_at}` \
tombstone marker. The entities from the prior session \
were adopted; the tombstone has been cleared. If this \
reattach was unexpected, run `memstead mem delete \
{mem}` to destroy the storage and start fresh."
),
WarningHint::ReadMemsMigratedToMounts {
mems,
from_host_mems,
} => write!(
f,
"legacy `readMems` registrations were migrated to \
workspace-level read-only mounts: [{}] (previously \
attached to writable mem(s) [{}]). The legacy key was \
removed from the config; this migration runs once. \
Remove a migrated read-mem with `memstead uninstall \
<name>`.",
mems.join(", "),
from_host_mems.join(", "),
),
WarningHint::EngineVersionSkew {
mem,
stamped_engine,
running_engine,
stamped_schema,
} => write!(
f,
"mem '{mem}': the last mutation was performed by engine \
v{stamped_engine} (against schema {stamped_schema}); \
this binary is engine v{running_engine}. Informative \
only — the next mutation re-stamps. If behaviour \
differs from the last session, the binary changed \
between them.",
),
WarningHint::SchemaGenerationsBehind {
mem,
pinned,
newest,
} => write!(
f,
"mem '{mem}' pins built-in schema {pinned}, but the \
catalogue registers newer generations up to {newest}. \
The pin keeps working (retained versions stay sealed); \
migrate via `memstead mem set-schema` when ready.",
),
WarningHint::FolderMemProvenance { mem } => write!(
f,
"mem '{mem}' was created on folder storage with no \
version control. Provenance here is the changelog \
ledger (`.memstead/changelog.jsonl`), which records \
every mutation with its note — but there are no \
commits: the `commit_sha` mutations return is a \
synthetic placeholder, and the content is not durable \
until the surrounding repository commits it."
),
WarningHint::SchemaAuthoringSourceMissing {
schema_ref,
stamped_path,
mems,
} => write!(
f,
"schema '{schema_ref}' (pinned by {}) was installed from \
'{stamped_path}', and that authoring package is no longer \
there. The engine keeps running on its sealed copy — \
nothing is broken — but the source the seal came from is \
gone: restore or move back the package, or re-install \
from its new location to re-stamp.",
mems.join(", ")
),
WarningHint::SchemaAuthoringSourceDiverged {
schema_ref,
stamped_path,
mems,
detail,
} => write!(
f,
"schema '{schema_ref}' (pinned by {}) no longer matches \
its authoring package at '{stamped_path}': {detail}. The \
engine keeps running on its sealed copy; if the authoring \
change is intended, bump the version and `memstead schema \
install` it.",
mems.join(", ")
),
WarningHint::MemFilesNotDeleted {
mem,
reason,
path,
error,
} => match (reason.as_str(), path.as_deref(), error.as_deref()) {
("rmdir_failed", Some(p), Some(e)) => write!(
f,
"mem '{mem}' was unregistered but rmdir of \
{p:?} failed: {e}. Files remain on disk; agent \
may follow up with manual cleanup."
),
("rmdir_failed", Some(p), None) => write!(
f,
"mem '{mem}' was unregistered but rmdir of \
{p:?} failed. Files remain on disk."
),
("backend_prune_failed", _, Some(e)) => write!(
f,
"mem '{mem}' was unregistered but backend \
artifact cleanup failed: {e}. The mem-repo \
branch and/or `__MEMSTEAD:mems/.../config.json` \
entry may survive; rerun delete with the same \
arguments or have an operator inspect."
),
("backend_prune_failed", _, None) => write!(
f,
"mem '{mem}' was unregistered but backend \
artifact cleanup failed. The mem-repo branch \
and/or `__MEMSTEAD` config entry may survive."
),
_ => write!(
f,
"mem '{mem}' was unregistered but \
`delete_files: true` did not run to completion \
(reason: {reason})."
),
},
}
}
}
impl WarningHint {
pub fn code(&self) -> &'static str {
match self {
Self::InlineWikiLinkAutoStubbed { .. } => "INLINE_WIKI_LINK_AUTO_STUBBED",
Self::CrossMemTargetMemUncreated { .. } => "CROSS_MEM_TARGET_MEM_UNCREATED",
Self::MissingRequiredSection { .. } => "MISSING_REQUIRED_SECTION",
Self::MissingRequiredField { .. } => "MISSING_REQUIRED_FIELD",
Self::UndeclaredRelationshipOpen { .. } => "UNDECLARED_RELATIONSHIP_OPEN",
Self::DuplicateRelationship { .. } => "DUPLICATE_RELATIONSHIP",
Self::NoSuchRelationship { .. } => "NO_SUCH_RELATIONSHIP",
Self::UnknownIncludeKey { .. } => "UNKNOWN_INCLUDE_KEY",
Self::LimitClamped { .. } => "LIMIT_CLAMPED",
Self::TitleNormalizedToSlugNoop { .. } => "TITLE_NORMALIZED_TO_SLUG_NOOP",
Self::TitleCharsDroppedFromSlug { .. } => "TITLE_CHARS_DROPPED_FROM_SLUG",
Self::UpdateNoop { .. } => "UPDATE_NOOP",
Self::StubFilterExcludesAll { .. } => "STUB_FILTER_EXCLUDES_ALL",
Self::UnknownFilterKey {
declared_on_other_types,
..
} => {
if declared_on_other_types.is_empty() {
"UNKNOWN_FILTER_KEY"
} else {
"FILTER_TYPE_SCOPED"
}
}
Self::FieldNotFilterable { .. } => "FIELD_NOT_FILTERABLE",
Self::FilterValueMultiMember { .. } => "FILTER_VALUE_MULTI_MEMBER",
Self::FilterValueNotInEnum { .. } => "INVALID_ENUM_VALUE",
Self::NeighbourhoodCapped { .. } => "NEIGHBOURHOOD_CAPPED",
Self::SearchResultsTruncated { .. } => "SEARCH_RESULTS_TRUNCATED",
Self::RangeFilterKeyMalformed { .. } => "RANGE_FILTER_KEY_MALFORMED",
Self::UnknownRangeFilterField {
declared_on_other_types,
..
} => {
if declared_on_other_types.is_empty() {
"UNKNOWN_RANGE_FILTER_FIELD"
} else {
"RANGE_FILTER_TYPE_SCOPED"
}
}
Self::FieldNotRangeFilterable { .. } => "FIELD_NOT_RANGE_FILTERABLE",
Self::SearchMemIndexUnavailable { .. } => "SEARCH_MEM_INDEX_UNAVAILABLE",
Self::TitleTrimmed { .. } => "TITLE_TRIMMED",
Self::SuspiciousNestedPrefix { .. } => "SUSPICIOUS_NESTED_PREFIX",
Self::NoteMissing { .. } => "NOTE_MISSING",
Self::IgnoredReadonlyField { .. } => "IGNORED_READONLY_FIELD",
Self::OuterRepoNotIgnoringMemRepo { .. } => "OUTER_REPO_NOT_IGNORING_MEM_REPO",
Self::MissingRequiredOutgoing { .. } => "MISSING_REQUIRED_OUTGOING",
Self::ConstraintUnsatisfied { .. } => "CONSTRAINT_UNSATISFIED",
Self::DuplicateSectionHeading { .. } => "DUPLICATE_SECTION_HEADING",
Self::MemReloaded { .. } => "MEM_RELOADED",
Self::SchemaPinMismatch { .. } => "SCHEMA_PIN_MISMATCH",
Self::EngineVersionSkew { .. } => "ENGINE_VERSION_SKEW",
Self::SchemaGenerationsBehind { .. } => "SCHEMA_GENERATIONS_BEHIND",
Self::SchemaHeadingRoundtripViolation { .. } => "SCHEMA_HEADING_ROUNDTRIP_VIOLATION",
Self::SectionHeadingDivergence { .. } => "SECTION_HEADING_DIVERGENCE",
Self::AutoStubCreated { .. } => "AUTO_STUB_CREATED",
Self::DerivationBaselineRefreshed { .. } => "DERIVATION_BASELINE_REFRESHED",
Self::SelfLinkIgnored { .. } => "SELF_LINK_IGNORED",
Self::ParsedRelationInvalid { .. } => "PARSED_RELATION_INVALID",
Self::ResidualStubForReadOnlyReferrers { .. } => "RESIDUAL_STUB_FOR_READONLY_REFERRERS",
Self::MemFilesNotDeleted { .. } => "MEM_FILES_NOT_DELETED",
Self::MemReattachedAfterUnregister { .. } => "MEM_REATTACHED_AFTER_UNREGISTER",
Self::ReadMemsMigratedToMounts { .. } => "READ_MEMS_MIGRATED_TO_MOUNTS",
Self::FolderMemProvenance { .. } => "FOLDER_MEM_PROVENANCE",
Self::SchemaAuthoringSourceMissing { .. } => "SCHEMA_AUTHORING_SOURCE_MISSING",
Self::SchemaAuthoringSourceDiverged { .. } => "SCHEMA_AUTHORING_SOURCE_DIVERGED",
Self::AmbiguousDescriptionDelimiter { .. } => "AMBIGUOUS_DESCRIPTION_DELIMITER",
Self::ParseMissingRequiredDescription { .. } => "MISSING_REQUIRED_DESCRIPTION",
Self::ParseDescriptionNotPermitted { .. } => "DESCRIPTION_NOT_PERMITTED",
}
}
pub fn message(&self) -> String {
self.to_string()
}
pub fn source_mem(&self) -> Option<&str> {
match self {
Self::SuspiciousNestedPrefix { from, .. } => Some(from.mem()),
Self::DuplicateSectionHeading { entity_id, .. } => Some(entity_id.mem()),
Self::SchemaPinMismatch { mem, .. } => Some(mem.as_str()),
Self::SchemaHeadingRoundtripViolation { mem, .. } => Some(mem.as_str()),
Self::SectionHeadingDivergence { entity_id, .. } => Some(entity_id.mem()),
Self::MemReloaded { mem, .. } => Some(mem.as_str()),
Self::MemFilesNotDeleted { mem, .. } => Some(mem.as_str()),
Self::MemReattachedAfterUnregister { mem, .. } => Some(mem.as_str()),
Self::ReadMemsMigratedToMounts { .. } => None,
Self::EngineVersionSkew { mem, .. } => Some(mem.as_str()),
Self::SchemaGenerationsBehind { mem, .. } => Some(mem.as_str()),
Self::FolderMemProvenance { mem } => Some(mem.as_str()),
Self::MissingRequiredOutgoing { entity_id, .. } => Some(entity_id.mem()),
Self::ConstraintUnsatisfied { entity_id, .. } => Some(entity_id.mem()),
Self::DuplicateRelationship { from, .. } => Some(from.mem()),
Self::NoSuchRelationship { from, .. } => Some(from.mem()),
Self::InlineWikiLinkAutoStubbed { from, .. } => Some(from.mem()),
Self::SelfLinkIgnored { id } => Some(id.mem()),
Self::CrossMemTargetMemUncreated { from_mem, .. } => Some(from_mem.as_str()),
Self::AutoStubCreated { stub_id, .. } => Some(stub_id.mem()),
Self::DerivationBaselineRefreshed { from, .. } => Some(from.mem()),
Self::UpdateNoop { id } => Some(id.mem()),
Self::ParsedRelationInvalid { entity_id, .. } => Some(entity_id.mem()),
Self::ResidualStubForReadOnlyReferrers { id, .. } => Some(id.mem()),
Self::AmbiguousDescriptionDelimiter { from, .. } => Some(from.mem()),
Self::ParseMissingRequiredDescription { from, .. } => Some(from.mem()),
Self::ParseDescriptionNotPermitted { from, .. } => Some(from.mem()),
Self::SearchMemIndexUnavailable { mem, .. } => Some(mem.as_str()),
_ => None,
}
}
pub fn all_samples() -> Vec<WarningHint> {
vec![
WarningHint::EngineVersionSkew {
mem: "m".into(),
stamped_engine: "0.3.0".into(),
running_engine: "0.4.0".into(),
stamped_schema: "default@1.0.0".into(),
},
WarningHint::SchemaGenerationsBehind {
mem: "m".into(),
pinned: "default@1.0.0".into(),
newest: "1.2.0".into(),
},
WarningHint::MissingRequiredSection {
entity_type: "t".into(),
key: "k".into(),
heading: "H".into(),
write_rules: vec![],
},
WarningHint::MissingRequiredField {
entity_type: "decision".into(),
key: "decided_on".into(),
description: "Date the decision was accepted.".into(),
enum_values: vec![],
},
WarningHint::UndeclaredRelationshipOpen {
rel_type: "X".into(),
message: "m".into(),
},
WarningHint::DuplicateRelationship {
rel_type: "X".into(),
from: EntityId("a".into()),
to: EntityId("b".into()),
},
WarningHint::NoSuchRelationship {
rel_type: "X".into(),
from: EntityId("a".into()),
to: EntityId("b".into()),
},
WarningHint::UnknownIncludeKey {
key: "x".into(),
allowed: vec![],
},
WarningHint::LimitClamped {
requested: 1,
actual: 1,
},
WarningHint::SearchResultsTruncated {
kept: 12,
budget: 12_000,
},
WarningHint::TitleNormalizedToSlugNoop {
requested_title: "Hello World!".into(),
current_slug: "hello-world".into(),
},
WarningHint::TitleCharsDroppedFromSlug {
title: "Acme Inc. & Co".into(),
dropped_chars: vec!['.', '&'],
slug: "acme-inc-co".into(),
},
WarningHint::UpdateNoop {
id: EntityId("specs--example".into()),
},
WarningHint::StubFilterExcludesAll {
entity_type: "spec".into(),
},
WarningHint::UnknownFilterKey {
key: "nonexistent_field".into(),
scoped_type: Some("spec".into()),
declared_on_other_types: vec!["decision".into()],
},
WarningHint::UnknownFilterKey {
key: "stauts".into(),
scoped_type: None,
declared_on_other_types: vec![],
},
WarningHint::FieldNotFilterable {
field: "title".into(),
},
WarningHint::RangeFilterKeyMalformed {
key: "weird_key".into(),
},
WarningHint::UnknownRangeFilterField {
field: "count".into(),
key: "min_count".into(),
scoped_type: None,
declared_on_other_types: vec![],
},
WarningHint::UnknownRangeFilterField {
field: "priority".into(),
key: "min_priority".into(),
scoped_type: Some("spec".into()),
declared_on_other_types: vec!["decision".into()],
},
WarningHint::FieldNotRangeFilterable {
field: "tags".into(),
},
WarningHint::SearchMemIndexUnavailable {
mem: "specs".into(),
reason: "missing_index",
error: None,
},
WarningHint::SuspiciousNestedPrefix {
from: EntityId("test-mem-plugin--audit-skill".into()),
resolved_id: EntityId("test-mem-plugin--plugin--memstead-mcp-tool-surface".into()),
candidate_target: Some(EntityId(
"test-mem-plugin--memstead-mcp-tool-surface".into(),
)),
section: "constraints".into(),
},
WarningHint::InlineWikiLinkAutoStubbed {
from: EntityId("specs--demo".into()),
stubs: vec![EntityId("specs--example-target".into())],
},
WarningHint::CrossMemTargetMemUncreated {
from_mem: "specs".into(),
to_mem: "memos".into(),
target_id: EntityId("memos--example".into()),
},
WarningHint::NoteMissing {
tool: "memstead_update".into(),
},
WarningHint::OuterRepoNotIgnoringMemRepo {
outer_repo_root: "/repos/demo".into(),
workspace_root: "/repos/demo/memstead".into(),
},
WarningHint::MissingRequiredOutgoing {
entity_type: "decision".into(),
entity_id: EntityId("planning--decision-x".into()),
missing: vec![
MissingRequiredOutgoingBlock {
relationships: vec!["CHOSEN".into()],
cardinality: "at_least_one".into(),
severity: memstead_schema::ConstraintSeverity::Warn,
},
MissingRequiredOutgoingBlock {
relationships: vec!["REJECTED".into()],
cardinality: "at_least_one".into(),
severity: memstead_schema::ConstraintSeverity::Warn,
},
],
},
WarningHint::DuplicateSectionHeading {
entity_id: EntityId("plugin--hooks-subsystem".into()),
section_key: "realization".into(),
heading: "Realization".into(),
occurrences: 3,
},
WarningHint::MemReloaded {
mem: "test-mem-plugin".into(),
old_head: "abc123".into(),
new_head: "def456".into(),
entities_loaded: 42,
},
WarningHint::AutoStubCreated {
stub_id: EntityId("specs--future-target".into()),
pending: false,
},
WarningHint::ParsedRelationInvalid {
entity_id: EntityId("specs--example-source".into()),
rel_type: "EXECUTES".into(),
target: EntityId("specs--example-target".into()),
reason: "shape".into(),
origin: "writable".into(),
recovery: Some(ParsedRelationRecovery::remove_explicit_relation(
EntityId("specs--example-source".into()),
EntityId("specs--example-target".into()),
"EXECUTES".into(),
)),
},
WarningHint::ResidualStubForReadOnlyReferrers {
id: EntityId("specs--archived-target".into()),
referrers: vec![EntityId("archive--archived-source".into())],
},
WarningHint::MemFilesNotDeleted {
mem: "plan-example".into(),
reason: "backend_prune_failed".into(),
path: None,
error: Some("ref-edit transaction rejected".into()),
},
WarningHint::MemReattachedAfterUnregister {
mem: "plan-example".into(),
unregistered_at: "2026-05-17T08:43:29Z".into(),
},
WarningHint::FolderMemProvenance {
mem: "plan-example".into(),
},
WarningHint::SchemaAuthoringSourceMissing {
schema_ref: "authored@0.1.0".into(),
stamped_path: "/workspace/authored".into(),
mems: vec!["specs".into()],
},
WarningHint::SchemaAuthoringSourceDiverged {
schema_ref: "authored@0.1.0".into(),
stamped_path: "/workspace/authored".into(),
mems: vec!["specs".into()],
detail: "the parsed authoring package differs from the sealed copy".into(),
},
WarningHint::AmbiguousDescriptionDelimiter {
from: EntityId("specs--example-source".into()),
rel_type: "OTHER".into(),
target: EntityId("specs--example-target".into()),
trailing: " -- legacy delimiter".into(),
},
WarningHint::ParseMissingRequiredDescription {
from: EntityId("specs--example-source".into()),
rel_type: "OTHER".into(),
target: EntityId("specs--example-target".into()),
},
WarningHint::ParseDescriptionNotPermitted {
from: EntityId("specs--example-source".into()),
rel_type: "IMPLEMENTS".into(),
target: EntityId("specs--example-target".into()),
},
]
}
fn details_payload(&self) -> serde_json::Value {
match self {
Self::MissingRequiredSection {
entity_type,
key,
heading,
write_rules,
} => serde_json::json!({
"entity_type": entity_type,
"key": key,
"heading": heading,
"write_rules": write_rules,
}),
Self::MissingRequiredField {
entity_type,
key,
description,
enum_values,
} => serde_json::json!({
"entity_type": entity_type,
"key": key,
"field_description": description,
"enum_values": enum_values,
}),
Self::UndeclaredRelationshipOpen { rel_type, .. } => {
serde_json::json!({ "rel_type": rel_type })
}
Self::DuplicateRelationship { rel_type, from, to } => {
serde_json::json!({ "rel_type": rel_type, "from": from, "to": to })
}
Self::NoSuchRelationship { rel_type, from, to } => {
serde_json::json!({ "rel_type": rel_type, "from": from, "to": to })
}
Self::UnknownIncludeKey { key, allowed } => {
serde_json::json!({ "key": key, "allowed": allowed })
}
Self::LimitClamped { requested, actual } => {
serde_json::json!({ "requested": requested, "actual": actual })
}
Self::TitleNormalizedToSlugNoop {
requested_title,
current_slug,
} => serde_json::json!({
"requested_title": requested_title,
"current_slug": current_slug,
}),
Self::TitleCharsDroppedFromSlug {
title,
dropped_chars,
slug,
} => serde_json::json!({
"title": title,
"dropped_chars": dropped_chars,
"slug": slug,
}),
Self::UpdateNoop { id } => serde_json::json!({ "id": id }),
Self::StubFilterExcludesAll { entity_type } => {
serde_json::json!({ "entity_type": entity_type })
}
Self::UnknownFilterKey {
key,
scoped_type,
declared_on_other_types,
} => serde_json::json!({
"key": key,
"scoped_type": scoped_type,
"declared_on_other_types": declared_on_other_types,
}),
Self::FieldNotFilterable { field } => serde_json::json!({ "field": field }),
Self::FilterValueMultiMember { key, value } => {
serde_json::json!({ "key": key, "value": value })
}
Self::FilterValueNotInEnum {
key,
value,
allowed,
} => {
serde_json::json!({ "key": key, "value": value, "allowed": allowed })
}
Self::NeighbourhoodCapped { kept, total } => {
serde_json::json!({ "kept": kept, "total": total })
}
Self::SearchResultsTruncated { kept, budget } => {
serde_json::json!({ "kept": kept, "budget": budget })
}
Self::RangeFilterKeyMalformed { key } => serde_json::json!({ "key": key }),
Self::UnknownRangeFilterField {
field,
key,
scoped_type,
declared_on_other_types,
} => serde_json::json!({
"field": field,
"key": key,
"scoped_type": scoped_type,
"declared_on_other_types": declared_on_other_types,
}),
Self::FieldNotRangeFilterable { field } => serde_json::json!({ "field": field }),
Self::SearchMemIndexUnavailable { mem, reason, error } => serde_json::json!({
"mem": mem,
"reason": reason,
"error": error,
}),
Self::TitleTrimmed { original, trimmed } => serde_json::json!({
"original": original,
"trimmed": trimmed,
}),
Self::SuspiciousNestedPrefix {
from,
resolved_id,
candidate_target,
section,
} => serde_json::json!({
"from": from,
"resolved_id": resolved_id,
"candidate_target": candidate_target,
"section": section,
}),
Self::InlineWikiLinkAutoStubbed { from, stubs } => serde_json::json!({
"from": from,
"stubs": stubs,
}),
Self::SelfLinkIgnored { id } => serde_json::json!({ "id": id }),
Self::CrossMemTargetMemUncreated {
from_mem,
to_mem,
target_id,
} => serde_json::json!({
"from_mem": from_mem,
"to_mem": to_mem,
"target_id": target_id,
}),
Self::NoteMissing { tool } => serde_json::json!({ "tool": tool }),
Self::IgnoredReadonlyField { field, supplied } => {
serde_json::json!({ "field": field, "supplied": supplied })
}
Self::OuterRepoNotIgnoringMemRepo {
outer_repo_root,
workspace_root,
} => serde_json::json!({
"outer_repo_root": outer_repo_root,
"workspace_root": workspace_root,
}),
Self::MissingRequiredOutgoing {
entity_type,
entity_id,
missing,
} => serde_json::json!({
"entity_type": entity_type,
"entity_id": entity_id,
"missing": missing,
}),
Self::ConstraintUnsatisfied {
entity_type,
entity_id,
violations,
} => serde_json::json!({
"entity_type": entity_type,
"entity_id": entity_id,
"violations": violations,
}),
Self::DuplicateSectionHeading {
entity_id,
section_key,
heading,
occurrences,
} => serde_json::json!({
"entity_id": entity_id,
"section_key": section_key,
"heading": heading,
"occurrences": occurrences,
}),
Self::MemReloaded {
mem,
old_head,
new_head,
entities_loaded,
} => serde_json::json!({
"mem": mem,
"old_head": old_head,
"new_head": new_head,
"entities_loaded": entities_loaded,
}),
Self::AutoStubCreated { stub_id, .. } => serde_json::json!({ "stub_id": stub_id }),
Self::DerivationBaselineRefreshed { from, rel_type, to } => serde_json::json!({
"from": from,
"rel_type": rel_type,
"to": to,
}),
Self::ParsedRelationInvalid {
entity_id,
rel_type,
target,
reason,
origin,
recovery,
} => {
serde_json::json!({
"entity_id": entity_id,
"rel_type": rel_type,
"target": target,
"reason": reason,
"origin": origin,
"recovery": recovery,
})
}
Self::ResidualStubForReadOnlyReferrers { id, referrers } => serde_json::json!({
"id": id,
"referrers": referrers,
}),
Self::MemFilesNotDeleted {
mem,
reason,
path,
error,
} => serde_json::json!({
"mem": mem,
"reason": reason,
"path": path,
"error": error,
}),
Self::MemReattachedAfterUnregister {
mem,
unregistered_at,
} => serde_json::json!({
"mem": mem,
"unregistered_at": unregistered_at,
}),
Self::EngineVersionSkew {
mem,
stamped_engine,
running_engine,
stamped_schema,
} => {
serde_json::json!({
"mem": mem,
"stamped_engine": stamped_engine,
"running_engine": running_engine,
"stamped_schema": stamped_schema,
})
}
Self::SchemaGenerationsBehind {
mem,
pinned,
newest,
} => serde_json::json!({
"mem": mem,
"pinned": pinned,
"newest": newest,
}),
Self::ReadMemsMigratedToMounts {
mems,
from_host_mems,
} => serde_json::json!({
"mems": mems,
"from_host_mems": from_host_mems,
}),
Self::FolderMemProvenance { mem } => serde_json::json!({
"mem": mem,
"ledger": ".memstead/changelog.jsonl",
"commit_sha": "synthetic placeholder (no version control)",
"durability": "content persists only when the surrounding repository commits it",
}),
Self::SchemaAuthoringSourceMissing {
schema_ref,
stamped_path,
mems,
} => serde_json::json!({
"schema_ref": schema_ref,
"stamped_path": stamped_path,
"mems": mems,
}),
Self::SchemaAuthoringSourceDiverged {
schema_ref,
stamped_path,
mems,
detail,
} => serde_json::json!({
"schema_ref": schema_ref,
"stamped_path": stamped_path,
"mems": mems,
"detail": detail,
}),
Self::AmbiguousDescriptionDelimiter {
from,
rel_type,
target,
trailing,
} => serde_json::json!({
"from": from,
"rel_type": rel_type,
"target": target,
"trailing": trailing,
}),
Self::ParseMissingRequiredDescription {
from,
rel_type,
target,
} => {
serde_json::json!({ "from": from, "rel_type": rel_type, "target": target })
}
Self::ParseDescriptionNotPermitted {
from,
rel_type,
target,
} => {
serde_json::json!({ "from": from, "rel_type": rel_type, "target": target })
}
Self::SchemaPinMismatch {
mem,
config_pin,
mount_pin,
} => {
serde_json::json!({
"mem": mem,
"config_pin": config_pin,
"mount_pin": mount_pin,
})
}
Self::SchemaHeadingRoundtripViolation {
mem,
schema_ref,
violations,
} => {
serde_json::json!({
"mem": mem,
"schema_ref": schema_ref,
"violations": violations,
})
}
Self::SectionHeadingDivergence {
entity_id,
section_key,
writing_heading,
existing_heading,
} => {
serde_json::json!({
"entity_id": entity_id,
"section_key": section_key,
"writing_heading": writing_heading,
"existing_heading": existing_heading,
})
}
}
}
}
impl Serialize for WarningHint {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let details = self.details_payload();
let mut state = serializer.serialize_struct("WarningHint", 3)?;
state.serialize_field("code", self.code())?;
state.serialize_field("message", &self.message())?;
state.serialize_field("details", &details)?;
state.end()
}
}
pub fn envelope(
code: &str,
message: impl Into<String>,
details: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({
"code": code,
"message": message.into(),
"details": details,
})
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateResult {
pub id: EntityId,
pub title: String,
pub mem: String,
pub file_path: String,
pub created_date: String,
#[serde(rename = "_hash")]
pub content_hash: String,
#[serde(default)]
pub commit_sha: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
#[serde(default)]
pub type_guidance: std::collections::BTreeMap<String, Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_count: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub incoming: Vec<IncomingRef>,
}
#[derive(Debug, Clone, Serialize)]
pub struct IncomingRef {
pub from: EntityId,
pub rel_type: String,
pub source: String,
}
pub fn project_incoming(edges: &[crate::store::InEdge]) -> Vec<IncomingRef> {
let mut out: Vec<IncomingRef> = edges
.iter()
.map(|e| IncomingRef {
from: e.from.clone(),
rel_type: e.rel_type.clone(),
source: match e.source {
crate::store::EdgeSource::Explicit => "explicit",
crate::store::EdgeSource::Hierarchy => "hierarchy",
crate::store::EdgeSource::BodyLink => "body_link",
}
.to_string(),
})
.collect();
out.sort_by(|a, b| a.rel_type.cmp(&b.rel_type).then(a.from.0.cmp(&b.from.0)));
out
}
#[derive(Debug, Clone, Serialize)]
pub struct DeleteResult {
pub id: EntityId,
pub relations_removed: usize,
#[serde(default)]
pub commit_sha: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub orphan_stubs_removed: Vec<EntityId>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RenameResult {
pub old_id: EntityId,
pub new_id: EntityId,
pub old_path: String,
pub new_path: String,
#[serde(default, rename = "_hash", skip_serializing_if = "String::is_empty")]
pub content_hash: String,
#[serde(default)]
pub commit_sha: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, Clone)]
pub struct RelateArg {
pub to: EntityId,
pub rel_type: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RelationUnsetArg {
pub rel_type: String,
pub target: EntityId,
}
#[derive(Debug, Clone, Serialize)]
pub struct RelateResult {
pub from: EntityId,
pub to: EntityId,
pub rel_type: String,
pub source: String,
#[serde(default, rename = "_hash", skip_serializing_if = "String::is_empty")]
pub content_hash: String,
#[serde(default)]
pub commit_sha: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
#[serde(skip)]
pub disk_changed: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub orphan_stubs_removed: Vec<EntityId>,
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
#[derive(Debug, Clone, Serialize)]
pub struct BatchResult {
pub applied: bool,
pub results: Vec<BatchEntry>,
pub succeeded: usize,
#[serde(default, skip_serializing_if = "is_zero")]
pub errors_suppressed: usize,
pub failed: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub orphan_stubs_removed: Vec<EntityId>,
#[serde(default)]
pub commit_sha: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct BatchEntry {
pub id: EntityId,
pub action: String,
pub error: Option<BatchError>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BatchError {
pub code: String,
pub message: String,
pub details: serde_json::Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct Query {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub any: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub not: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phrase: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
}
impl Query {
pub fn is_empty(&self) -> bool {
self.any.is_empty() && self.not.is_empty() && self.phrase.is_none()
}
}
#[derive(Debug, Clone, Default)]
pub struct SearchScope {
pub query: Option<Query>,
pub mem: Option<String>,
pub entity_type: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
pub filters: HashMap<String, String>,
pub range_filters: HashMap<String, String>,
pub edge_type: Option<String>,
pub related_to: Option<EntityId>,
pub depth: Option<usize>,
pub expand_via: Option<Vec<String>>,
pub expand_depth: Option<usize>,
pub direction: crate::graph::query::TraversalDirection,
pub stub: Option<bool>,
pub token_budget: Option<usize>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ScoreBreakdown {
pub bm25: f32,
pub title_boost: f32,
pub field_weights: HashMap<String, f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expansion_decay: Option<f32>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct TermMatch {
pub field: String,
pub snippet: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub heading_path: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ExpansionInfo {
pub of: EntityId,
pub via_edge: String,
pub depth: usize,
pub via_direction: crate::graph::query::TraversalDirection,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct SubsectionFacet {
pub path: Vec<String>,
pub count: usize,
}
#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
pub struct Facets {
pub by_type: HashMap<String, usize>,
pub by_mem: HashMap<String, usize>,
pub by_level: HashMap<String, usize>,
pub by_status: HashMap<String, usize>,
pub by_confidence: HashMap<String, usize>,
pub by_subsection: Vec<SubsectionFacet>,
pub by_expansion: HashMap<String, usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchHit {
pub id: EntityId,
pub title: String,
pub mem: String,
pub entity_type: String,
pub stub: bool,
pub score: f32,
pub tokens: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_modified: Option<String>,
pub snippet: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub sections: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score_breakdown: Option<ScoreBreakdown>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matched_terms: Option<HashMap<String, Vec<TermMatch>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expansion: Option<ExpansionInfo>,
#[serde(skip)]
pub summary: Option<SummaryPair>,
}
#[derive(Debug, Clone)]
pub struct SummaryPair {
pub heading: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchResult {
pub total: usize,
pub returned: usize,
pub offset: usize,
pub total_tokens: usize,
pub hits: Vec<SearchHit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub facets: Option<Facets>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ListResult {
pub total: usize,
pub returned: usize,
pub offset: usize,
pub total_tokens: usize,
pub hits: Vec<SearchHit>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthReport {
pub id: EntityId,
pub title: String,
pub score: f32,
pub issues: Vec<HealthIssue>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum HealthIssueCode {
Missing,
SectionHeadingMismatch,
UndeclaredRelationship,
InvalidRelShape,
}
impl HealthIssueCode {
pub fn as_wire(&self) -> &'static str {
match self {
HealthIssueCode::Missing => "MISSING",
HealthIssueCode::SectionHeadingMismatch => "SECTION_HEADING_MISMATCH",
HealthIssueCode::UndeclaredRelationship => "UNDECLARED_RELATIONSHIP",
HealthIssueCode::InvalidRelShape => "INVALID_REL_SHAPE",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthIssue {
pub field: String,
pub code: HealthIssueCode,
pub message: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct QuarantinedMemReport {
pub mem: String,
pub reason_code: String,
pub reason_message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthSummary {
pub stale_entities: Vec<StaleEntity>,
pub missing_fields: Vec<HealthReport>,
pub orphan_count: usize,
pub stub_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quarantined: Vec<QuarantinedMemReport>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub boot_diagnosis: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub leaf_entities_by_type: std::collections::BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dangling_links: Option<Vec<DanglingLink>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub findings: Option<Vec<integrity::IntegrityFinding>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag_distribution: Option<Vec<TagDistribution>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag_distribution_folded: Option<Vec<FoldedTag>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub untagged_entities: Option<UntaggedStats>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StaleEntity {
pub id: EntityId,
pub title: String,
pub days_since_modified: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct TagDistribution {
pub tag: String,
pub count: usize,
pub by_entity_type: HashMap<String, usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FoldedTag {
pub canonical: String,
pub total: usize,
pub variants: Vec<TagVariant>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TagVariant {
pub tag: String,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct UntaggedStats {
pub total: usize,
pub by_entity_type: HashMap<String, usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DanglingLink {
pub from: EntityId,
pub target_id: EntityId,
pub target_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExportResult {
pub written: usize,
pub unchanged: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skipped_mounts: Vec<SkippedMount>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SkippedMount {
pub mem: String,
pub active_backend: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct MemExportResult {
pub archive_path: String,
pub name: String,
pub version: String,
pub entity_count: usize,
pub size_bytes: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dangling_cross_mem_edges: Vec<crate::validator::DanglingCrossMemEdge>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SetMemVersionOutcome {
pub mem: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_version: Option<semver::Version>,
pub new_version: semver::Version,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SetMemTitleOutcome {
pub mem: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_title: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SetMemSubjectOutcome {
pub mem: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_subject: Option<memstead_schema::MemSubject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_subject: Option<memstead_schema::MemSubject>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SetMemDescriptionOutcome {
pub mem: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SetMemSyncStateOutcome {
pub mem: String,
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous: Option<String>,
pub removed: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ContextResult {
pub entity_id: EntityId,
pub community: Option<String>,
pub neighbors: Vec<NeighborInfo>,
}
#[derive(Debug, Clone, Serialize)]
pub struct NeighborInfo {
pub id: EntityId,
pub title: String,
pub relationship: String,
pub direction: Direction,
}
#[derive(Debug, Clone, Serialize)]
pub enum Direction {
Outgoing,
Incoming,
}
#[derive(Debug, Clone, Serialize)]
pub struct Status {
pub entity_count: usize,
pub edge_count: usize,
pub edge_types: HashMap<String, usize>,
pub community_count: usize,
pub mem_count: usize,
pub types_in_use: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReloadResult {
pub added: Vec<EntityId>,
pub changed: Vec<EntityId>,
pub removed: Vec<EntityId>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReloadReport {
pub mem: String,
pub head_before: String,
pub head_after: String,
pub entities_loaded: usize,
pub changed_entity_ids: Vec<EntityId>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct FullRefreshReport {
pub schemas_added: Vec<String>,
pub schema_removals_skipped: Vec<String>,
pub mems_mounted: Vec<String>,
pub mem_removals_skipped: Vec<String>,
pub failures: Vec<RefreshFailure>,
pub elapsed_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct RefreshFailure {
pub item: String,
pub error: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_json_roundtrip_every_combination() {
let cases: Vec<Query> = vec![
Query::default(),
Query {
any: vec!["auth".into()],
..Default::default()
},
Query {
not: vec!["mock".into()],
..Default::default()
},
Query {
phrase: Some("client side agent".into()),
..Default::default()
},
Query {
field: Some("identity".into()),
..Default::default()
},
Query {
any: vec!["a".into(), "b".into()],
not: vec!["x".into()],
phrase: Some("ex act".into()),
field: Some("purpose".into()),
},
];
for q in &cases {
let json = serde_json::to_string(q).expect("serialize");
let back: Query = serde_json::from_str(&json).expect("deserialize");
assert_eq!(q.any, back.any, "any field round-trip: {json}");
assert_eq!(q.not, back.not, "not field round-trip: {json}");
assert_eq!(q.phrase, back.phrase, "phrase field round-trip: {json}");
assert_eq!(q.field, back.field, "field field round-trip: {json}");
assert_eq!(q.is_empty(), back.is_empty());
}
}
#[test]
fn query_default_serializes_as_empty_object() {
let q = Query::default();
let json = serde_json::to_string(&q).unwrap();
assert_eq!(json, "{}", "default query must serialize as `{{}}`");
}
#[test]
fn query_accepts_missing_and_null_fields() {
let with_missing: Query = serde_json::from_str("{}").unwrap();
let with_nulls: Query =
serde_json::from_str(r#"{"any":[],"not":[],"phrase":null,"field":null}"#).unwrap();
assert!(with_missing.is_empty());
assert!(with_nulls.is_empty());
}
#[test]
fn query_json_schema_exposes_four_fields() {
let schema = schemars::schema_for!(Query);
let rendered = serde_json::to_string(&schema).unwrap();
for field in ["any", "not", "phrase", "field"] {
assert!(
rendered.contains(&format!("\"{field}\"")),
"schema must mention `{field}`: {rendered}"
);
}
}
fn to_envelope(w: &WarningHint) -> serde_json::Value {
serde_json::to_value(w).expect("WarningHint serializes")
}
#[test]
fn warning_hint_missing_required_section_envelope() {
let w = WarningHint::MissingRequiredSection {
entity_type: "spec".into(),
key: "purpose".into(),
heading: "Purpose".into(),
write_rules: vec!["one sentence".into(), "state the why".into()],
};
let json = to_envelope(&w);
assert_eq!(json["code"], "MISSING_REQUIRED_SECTION");
assert!(
json["message"]
.as_str()
.unwrap()
.contains("required section")
);
assert_eq!(json["details"]["entity_type"], "spec");
assert_eq!(json["details"]["key"], "purpose");
assert_eq!(json["details"]["heading"], "Purpose");
assert!(json["details"]["write_rules"].is_array());
assert!(json["details"].get("type_write_rules").is_none());
}
#[test]
fn warning_hint_undeclared_relationship_open_envelope() {
let w = WarningHint::UndeclaredRelationshipOpen {
rel_type: "USES".into(),
message: "USES admitted in open mode".into(),
};
let json = to_envelope(&w);
assert_eq!(json["code"], "UNDECLARED_RELATIONSHIP_OPEN");
assert!(json["message"].as_str().unwrap().contains("open mode"));
assert_eq!(json["details"]["rel_type"], "USES");
assert!(json["details"].get("message").is_none());
assert_eq!(json["details"].as_object().unwrap().len(), 1);
}
#[test]
fn warning_hint_duplicate_relationship_envelope() {
let w = WarningHint::DuplicateRelationship {
rel_type: "USES".into(),
from: EntityId("specs--a".into()),
to: EntityId("specs--b".into()),
};
let json = to_envelope(&w);
assert_eq!(json["code"], "DUPLICATE_RELATIONSHIP");
assert!(json["message"].as_str().unwrap().contains("already exists"));
assert_eq!(json["details"]["rel_type"], "USES");
assert_eq!(json["details"]["from"], "specs--a");
assert_eq!(json["details"]["to"], "specs--b");
}
#[test]
fn warning_hint_no_such_relationship_envelope() {
let w = WarningHint::NoSuchRelationship {
rel_type: "USES".into(),
from: EntityId("specs--a".into()),
to: EntityId("specs--b".into()),
};
let json = to_envelope(&w);
assert_eq!(json["code"], "NO_SUCH_RELATIONSHIP");
assert!(json["message"].as_str().unwrap().contains("does not exist"));
assert_eq!(json["details"]["rel_type"], "USES");
assert_eq!(json["details"]["from"], "specs--a");
assert_eq!(json["details"]["to"], "specs--b");
}
#[test]
fn warning_hint_unknown_include_key_envelope() {
let w = WarningHint::UnknownIncludeKey {
key: "bogus".into(),
allowed: vec!["orphans".into(), "stubs".into()],
};
let json = to_envelope(&w);
assert_eq!(json["code"], "UNKNOWN_INCLUDE_KEY");
assert!(json["message"].as_str().unwrap().contains("bogus"));
assert_eq!(json["details"]["key"], "bogus");
assert!(json["details"]["allowed"].is_array());
}
#[test]
fn warning_hint_limit_clamped_envelope() {
let w = WarningHint::LimitClamped {
requested: 1000,
actual: 100,
};
let json = to_envelope(&w);
assert_eq!(json["code"], "LIMIT_CLAMPED");
assert!(json["message"].as_str().unwrap().contains("clamped"));
assert_eq!(json["details"]["requested"].as_u64(), Some(1000));
assert_eq!(json["details"]["actual"].as_u64(), Some(100));
}
#[test]
fn warning_hint_title_normalized_to_slug_noop_envelope() {
let w = WarningHint::TitleNormalizedToSlugNoop {
requested_title: "Hello World!".into(),
current_slug: "hello-world".into(),
};
let json = to_envelope(&w);
assert_eq!(json["code"], "TITLE_NORMALIZED_TO_SLUG_NOOP");
assert!(
json["message"]
.as_str()
.unwrap()
.contains("no change written to disk")
);
assert_eq!(json["details"]["requested_title"], "Hello World!");
assert_eq!(json["details"]["current_slug"], "hello-world");
}
#[test]
fn warning_hint_envelope_has_exactly_three_top_level_keys() {
for w in &WarningHint::all_samples() {
let json = to_envelope(w);
let obj = json.as_object().expect("envelope is an object");
assert_eq!(
obj.len(),
3,
"{} must emit exactly 3 top-level keys; got {:?}",
w.code(),
obj.keys().collect::<Vec<_>>()
);
assert!(obj.contains_key("code"));
assert!(obj.contains_key("message"));
assert!(obj.contains_key("details"));
}
}
#[test]
fn warning_hint_code_values_are_upper_snake_case() {
let re = regex::Regex::new(r"^[A-Z][A-Z0-9_]*$").unwrap();
for w in &WarningHint::all_samples() {
let code = w.code();
assert!(
re.is_match(code),
"code() violates UPPER_SNAKE_CASE: {code}"
);
}
}
#[test]
fn envelope_shape_is_code_message_details() {
let v = envelope("FOO_BAR", "hello", serde_json::json!({ "x": 1 }));
assert_eq!(v["code"], "FOO_BAR");
assert_eq!(v["message"], "hello");
assert_eq!(v["details"]["x"], 1);
assert_eq!(
v.as_object().unwrap().len(),
3,
"envelope has exactly 3 top-level keys"
);
}
}