diff --git a/crates/coven-threads-core/src/identity_invariants.rs b/crates/coven-threads-core/src/identity_invariants.rs index f99d888..ae701f8 100644 --- a/crates/coven-threads-core/src/identity_invariants.rs +++ b/crates/coven-threads-core/src/identity_invariants.rs @@ -388,8 +388,15 @@ fn parse_identity_declaration(value: &str) -> Result return Err("expected `==` or `includes` operator".into()), }; - let fact = IdentityFact::from_declaration_name(left.trim()) - .ok_or_else(|| format!("unsupported identity fact {:?}", left.trim()))?; + // Never echo left-of-operator text: a reversed declaration (e.g. + // `'' == familiar.person`) would carry principal-adjacent values + // into migration reports and stdout. Name the expected grammar instead. + let fact = IdentityFact::from_declaration_name(left.trim()).ok_or_else(|| { + "unsupported identity fact left of operator; expected one of: \ + familiar.name, familiar.person, familiar.pronouns, familiar.purpose, \ + familiar.coven" + .to_string() + })?; let right = right.trim(); if right.is_empty() { return Err("expected value must not be empty".into()); @@ -1045,6 +1052,22 @@ mod tests { assert!(errors.iter().any(|error| error.contains("Name"))); } + #[test] + fn unsupported_fact_rejection_never_echoes_left_of_operator_text() { + // Reversed declaration: principal-adjacent text sits left of the + // operator. The rejection must name the grammar, never the text. + let errors = IdentityInvariantSet::compile([ + r#"'Val Alexander' == familiar.person"#, + r#"familiar.name == "Nova""#, + r#"familiar.person == "Val""#, + ]) + .unwrap_err(); + assert_eq!(errors.len(), 1); + assert!(errors[0].starts_with("invariant[0]: unsupported identity fact")); + assert!(errors[0].contains("familiar.pronouns")); + assert!(!errors[0].contains("Val Alexander")); + } + #[test] fn compiler_uses_the_first_declared_operator_not_rhs_text() { let invariants = IdentityInvariantSet::compile([