From e38149440028967599734aa043220790b7ad886f Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 24 Jul 2026 06:13:27 -0500 Subject: [PATCH] fix(identity-invariants): never echo left-of-operator text in unsupported-fact rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit threads-ype (N-2 from coven PR #464 fresh-context review): parse_identity_declaration echoed the left-of-operator text verbatim in the 'unsupported identity fact' rejection. A reversed declaration like 'Val Alexander' == familiar.person would carry principal-adjacent text into migration reports and stdout. The rejection now names only the expected grammar (the five supported familiar.* facts) and never echoes input. The 'unsupported identity fact' prefix is preserved — downstream assertions in the retired-Ward corpus and coven ward_migrate tests match on that prefix only. Adds a regression test proving a reversed declaration is rejected without echoing the principal value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/identity_invariants.rs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/coven-threads-core/src/identity_invariants.rs b/crates/coven-threads-core/src/identity_invariants.rs index 6af8e0e..f227ace 100644 --- a/crates/coven-threads-core/src/identity_invariants.rs +++ b/crates/coven-threads-core/src/identity_invariants.rs @@ -389,8 +389,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()); @@ -1046,6 +1053,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([