Close RS-L's four fixes: OrgRepo unequal-direction pins, schema description split, count fix, campaign.repos duplicate refusal

Soul's third RS-L pass ruled the leaf's five promises hold but found four
closing items (soul-e20-notes.md, 2026-09-22):

1. Every OrgRepo Eq/Hash/Ord/PartialOrd assertion compared values that
   should already be equal, so eq -> true, hash -> (), cmp -> Equal,
   partial_cmp -> None and partial_cmp -> self.0.partial_cmp(&other.0) all
   survived as mutants. Add the unequal direction (assert_ne! on two
   different repos, differing hashes, a non-Equal ordering) and std's
   partial_cmp(a,b) == Some(cmp(a,b)) contract for both an equal and an
   unequal pair.
2. The published OrgRepo schema description carried ~1,100 characters of
   Rust-macro justification into nine contracts. Split the doc comment: the
   grammar, the 140-byte bound and the case-insensitive identity stay in the
   published /// doc comment; the bounded_text! delegation reasoning moves to
   a plain // source comment, so it stops reaching schemars.
3. lib.rs said "eleven bidi control code points" above twelve fixtures
   (ALM, LRM, RLM, LRE, RLE, PDF, LRO, RLO, LRI, RLI, FSI, PDI). Say twelve.
4. campaign.repos admitted both spellings of one repository. Add
   PipelineRefusal::DuplicateRepo and a campaign-only validation hook
   (value_types!'s new optional `=> extra` clause) that refuses two entries
   sharing an OrgRepo identity; distinct repos still validate.

This commit is the source half; the nine published schemas are regenerated
in the next commit to match fix 2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 20:18:24 +02:00
commit 38bc538110

View file

@ -60,6 +60,7 @@ pub enum PipelineRefusal {
FieldBound { field: String, limit: u32, actual: u32 },
InvalidFormat { field: String, value: String },
InvalidIdentity { kind: PipelineKind, key: String, expected: String },
DuplicateRepo { field: String, repo: String },
ForeignStore { r#type: String },
}
@ -275,19 +276,24 @@ impl Slug {
/// 100). The owner is 1 to 39 `[A-Za-z0-9-]` bytes with no leading or
/// trailing hyphen; the repo is 1 to 100 `[A-Za-z0-9._-]` bytes, is never
/// `.` or `..`, and does not end in `.git`, ASCII-case-insensitively.
///
/// The raw string is display-only. `OrgRepo` is not generated by
/// `bounded_text!`: that macro derives `PartialEq`, `Eq`, `Hash`, `PartialOrd`
/// and `Ord` on the raw string, with no per-member override, so a type built
/// from it compares and keys on spelling. `OrgRepo` instead carries
/// hand-written impls of those five traits, every one of them over
/// `identity()` (the ASCII-lowercased value), so `==`, a `BTreeMap` key and a
/// `HashMap` key are all case-insensitive by construction: `GameCult/Epiphany`
/// and `gamecult/epiphany` compare equal, hash equal, order together and
/// collapse to one map entry, and a caller cannot reach the case-sensitive
/// behaviour by accident. `Hash` and `Eq` agree because both are defined over
/// the same `identity()` string. Every other bounded type keeps the macro's
/// derives untouched.
/// Identity is case-insensitive: `GameCult/Epiphany` and `gamecult/epiphany`
/// are the same repository.
//
// The raw string is display-only. `OrgRepo` is not generated by
// `bounded_text!`: that macro derives `PartialEq`, `Eq`, `Hash`, `PartialOrd`
// and `Ord` on the raw string, with no per-member override, so a type built
// from it compares and keys on spelling. `OrgRepo` instead carries
// hand-written impls of those five traits, every one of them over
// `identity()` (the ASCII-lowercased value), so `==`, a `BTreeMap` key and a
// `HashMap` key are all case-insensitive by construction: `GameCult/Epiphany`
// and `gamecult/epiphany` compare equal, hash equal, order together and
// collapse to one map entry, and a caller cannot reach the case-sensitive
// behaviour by accident. `Hash` and `Eq` agree because both are defined over
// the same `identity()` string. Every other bounded type keeps the macro's
// derives untouched. This reasoning is a source comment, not a doc comment,
// so it does not enter the published schema description (RS-L closing fix 2):
// a consumer needs the grammar, the bound and the case-insensitivity, not the
// Rust-macro justification for how that got built.
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
#[schemars(extend("maxLength" = 140))]
@ -392,7 +398,7 @@ macro_rules! bounded_field {
/// The single field list: one invocation emits the value struct and its
/// `Bounded` impl, so a field can never be left out of validation.
macro_rules! value_types {
($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? })*) => {$(
($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? } $(=> $extra:path)?)*) => {$(
$(#[$attr])*
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct $name {
@ -405,6 +411,7 @@ macro_rules! value_types {
impl Bounded for $name {
fn validate(&self, at: &str) -> Result<(), PipelineRefusal> {
$(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)*
$($extra(self, at)?;)?
Ok(())
}
}
@ -423,6 +430,26 @@ macro_rules! unit_enums {
)*};
}
/// RS-L closing fix 4: `campaign.repos` refuses two entries naming the same
/// repository by identity, not by raw spelling, so `GameCult/Epiphany` and
/// `gamecult/epiphany` in one list refuse exactly as one entry twice would.
/// This is a cross-element invariant on `PipelineCampaign` alone, not a
/// per-type format rule any other `value_types!` struct shares, so it is
/// wired in as that struct's one `=> extra` hook rather than folded into
/// `Bounded for OrgRepo` or the list bound in `bounded_field!`.
fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> {
let mut seen = std::collections::HashSet::new();
for repo in &campaign.repos {
if !seen.insert(repo.identity()) {
return Err(PipelineRefusal::DuplicateRepo {
field: format!("{at}.repos"),
repo: repo.0.clone(),
});
}
}
Ok(())
}
unit_enums! {
EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture }
ClaimOutcome { Holds, Falsified, Unproven }
@ -480,7 +507,7 @@ value_types! {
promise: Option<Label>, mutations: Vec<Label>[8],
}
pub struct PipelineCampaign { slug: Slug, title: Title, repos: Vec<OrgRepo>[8], working_branch: Short, target_doc: DocRef }
pub struct PipelineCampaign { slug: Slug, title: Title, repos: Vec<OrgRepo>[8], working_branch: Short, target_doc: DocRef } => campaign_repos_are_distinct
pub struct PipelineTarget {
campaign: Slug, revision: u32, invariants: Vec<TargetInvariant>[32], not_in_scope: Vec<Line>[32],
canonical_implementations: Vec<Line>[16], doc: DocRef,
@ -1282,6 +1309,35 @@ mod tests {
);
}
/// RS-L closing fix 4: `campaign.repos` refuses two entries naming one
/// repository by identity, not only by raw spelling, so `GameCult/Epiphany`
/// and `gamecult/epiphany` in one list refuse exactly as the same spelling
/// twice would. Two genuinely distinct repos still validate.
#[test]
fn campaign_repos_refuse_duplicates_by_identity() {
let PipelineDocument::Campaign(mut campaign) = campaign_sample() else { unreachable!() };
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("gamecult/epiphany".into())];
assert_eq!(
PipelineDocument::Campaign(campaign.clone()).validate(),
Err(PipelineRefusal::DuplicateRepo {
field: "campaign.repos".into(),
repo: "gamecult/epiphany".into(),
})
);
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()); 2];
assert_eq!(
PipelineDocument::Campaign(campaign.clone()).validate(),
Err(PipelineRefusal::DuplicateRepo {
field: "campaign.repos".into(),
repo: "GameCult/Epiphany".into(),
})
);
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Aetheria".into())];
assert_eq!(PipelineDocument::Campaign(campaign).validate(), Ok(()));
}
#[test]
fn repo_fields_must_be_org_slash_repo() {
let mut report = report_sample();
@ -2448,6 +2504,7 @@ mod tests {
key: format!("{CAMPAIGN}:campaign:forged"),
expected: format!("{CAMPAIGN}:campaign:self"),
},
PipelineRefusal::DuplicateRepo { field: "campaign.repos".into(), repo: "gamecult/epiphany".into() },
PipelineRefusal::ForeignStore { r#type: <ForeignDocument as DatabaseEntry>::TYPE.into() },
];
for refusal in &refusals {
@ -2489,7 +2546,7 @@ mod tests {
properties.keys().next().expect("one key").to_owned()
})
.collect::<Vec<_>>();
assert_eq!(named, vec!["FieldBound", "InvalidFormat", "InvalidIdentity", "ForeignStore"]);
assert_eq!(named, vec!["FieldBound", "InvalidFormat", "InvalidIdentity", "DuplicateRepo", "ForeignStore"]);
assert_eq!(named.len(), refusals.len(), "every refusal variant is in the schema and in the round trip");
Ok(())
}
@ -2763,6 +2820,45 @@ mod tests {
// repository wherever `OrgRepo` is compared, hashed or ordered.
assert_eq!(OrgRepo::from("GameCult/Epiphany"), OrgRepo::from("gamecult/epiphany"));
assert_eq!(OrgRepo::from("GameCult/Epiphany").cmp(&OrgRepo::from("gamecult/epiphany")), std::cmp::Ordering::Equal);
assert_eq!(
OrgRepo::from("GameCult/Epiphany").partial_cmp(&OrgRepo::from("gamecult/epiphany")),
Some(OrgRepo::from("GameCult/Epiphany").cmp(&OrgRepo::from("gamecult/epiphany"))),
"partial_cmp must agree with cmp for an equal pair"
);
// RS-L closing fix 1: every assertion above compares values that
// should be equal, so `eq -> true`, `hash -> ()` and `cmp -> Equal`
// all survive as mutants. Pin the unequal direction as well: two
// different repos must be unequal, hash differently (with
// overwhelming probability, not by construction, so this is belt and
// suspenders on top of the `!=` and `cmp` checks), and not compare
// `Equal`, and `partial_cmp` must agree with `cmp` for an unequal
// pair too, per std's own contract that `partial_cmp(a, b) ==
// Some(cmp(a, b))`.
let a = OrgRepo::from("GameCult/Epiphany");
let b = OrgRepo::from("GameCult/Aetheria");
assert_ne!(a, b, "two different repos must not compare equal");
assert_ne!(
a.cmp(&b),
std::cmp::Ordering::Equal,
"two different repos must not order as Equal"
);
assert_eq!(
a.partial_cmp(&b),
Some(a.cmp(&b)),
"partial_cmp must agree with cmp for an unequal pair"
);
let hash_of = |repo: &OrgRepo| {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
repo.hash(&mut hasher);
hasher.finish()
};
assert_ne!(
hash_of(&a),
hash_of(&b),
"two different repos must hash differently"
);
let mut hash_map = std::collections::HashMap::new();
hash_map.insert(OrgRepo::from("GameCult/Epiphany"), 1);
@ -2865,7 +2961,7 @@ mod tests {
refused("\u{a0}"); // NBSP alone
refused("\u{1f680}"); // emoji only
// S-3: every one of the eleven bidi control code points is pinned
// S-3: every one of the twelve bidi control code points is pinned
// with an embedded fixture (real alphanumeric text on both sides), so
// the alphanumeric rule cannot mask a hole in the bidi match arm the
// way a lone-character fixture would.