Make the repo key escape injective
Soul F1. `/` -> `_` is not injective: `GameCult_Epiphany/thing` and `GameCult/Epiphany_thing` both key to `yggdrasil:stewardship:GameCult_Epiphany_thing`, so an instance stewarding both silently loses one document. Keys are identity, not convenience. `_` is now escaped too, and both codes are two bytes starting with `_`: `_` becomes `__` and `/` becomes `_-`. Every other byte passes through and is never `_`, so a reader going left to right takes each `_` with the byte after it and never has a choice to make. The encoding is reversible, therefore injective. That is the smallest change that buys the property: nothing else moves, and the segment stays inside the `Label` byte class. The test claimed the property and checked one half of the colliding pair, which asserts nothing about collision. It now keys both halves and asserts they differ. M12 is re-anchored on the new spelling, and M13 is new: it puts the shipped `/` -> `_` back, which the pair kills. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
08c1c9d907
commit
eb55efe9c9
2 changed files with 52 additions and 21 deletions
|
|
@ -585,13 +585,20 @@ fn parent_cut<'a>(field: &str, local: &'a str, marker: char) -> Result<&'a str,
|
|||
Ok(cut)
|
||||
}
|
||||
|
||||
/// A repo as one key segment: the `OrgRepo` with its `/` replaced by `_`,
|
||||
/// because `/` is not a `Label` byte and a composed key must segment one way
|
||||
/// only. The caller checks the result against the key's segment rules, which is
|
||||
/// where an over-long repo name is refused.
|
||||
/// A repo as one key segment. `/` is not a `Label` byte, so it cannot survive
|
||||
/// into a key, but the escape that replaces it has to be injective or two repos
|
||||
/// claim one key: replacing `/` with `_` alone is not, since
|
||||
/// `GameCult_Epiphany/thing` and `GameCult/Epiphany_thing` both give
|
||||
/// `GameCult_Epiphany_thing`. So `_` is escaped as well, and both codes are two
|
||||
/// bytes starting with `_`: `_` becomes `__` and `/` becomes `_-`. Every other
|
||||
/// byte is passed through and is never `_`, so a reader going left to right
|
||||
/// takes each `_` together with the byte after it and never has a choice to
|
||||
/// make; the encoding is therefore reversible, and distinct repos give distinct
|
||||
/// segments. The caller checks the result against the key's segment rules,
|
||||
/// which is where an over-long repo name is refused.
|
||||
fn repo_segment(field: &str, repo: &OrgRepo) -> Result<String, PipelineRefusal> {
|
||||
org_repo_text(field, &repo.0)?;
|
||||
Ok(repo.0.replace('/', "_"))
|
||||
Ok(repo.0.replace('_', "__").replace('/', "_-"))
|
||||
}
|
||||
|
||||
/// Derives a document's identity key (D1, "Keys: identity, not convenience").
|
||||
|
|
@ -833,12 +840,12 @@ mod tests {
|
|||
(D::Stewardship(PipelineStewardship {
|
||||
instance: slug(INSTANCE), repo: repo(), assigned_on: date(),
|
||||
note: "The Eureka campaign repo.".into(),
|
||||
}), format!("{INSTANCE}:stewardship:GameCult_Epiphany")),
|
||||
}), format!("{INSTANCE}:stewardship:GameCult_-Epiphany")),
|
||||
(D::HandOff(PipelineHandOff {
|
||||
from_instance: slug(INSTANCE), to_instance: slug("thought-cage"), repo: repo(),
|
||||
documents: vec![s(CAMPAIGN), id("ruling", "R8")],
|
||||
reason: "The workstation mind takes the campaign.".into(), handed_on: date(),
|
||||
}), format!("{INSTANCE}:hand_off:thought-cage.GameCult_Epiphany.{}", date().0)),
|
||||
}), format!("{INSTANCE}:hand_off:thought-cage.GameCult_-Epiphany.{}", date().0)),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1146,25 +1153,37 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The repo is one key segment, so its slash is escaped to `_`. Left
|
||||
/// unescaped it would both add a segment the reader cannot tell from a
|
||||
/// real one and put a byte in the key that no `Label` may carry.
|
||||
/// The repo is one key segment, so its slash is escaped. Left unescaped it
|
||||
/// would both add a segment the reader cannot tell from a real one and put a
|
||||
/// byte in the key that no `Label` may carry; escaped to a bare `_` it would
|
||||
/// let two repos claim one key, which the colliding pair below pins.
|
||||
#[test]
|
||||
fn stewardship_key_escapes_the_repo_slash() {
|
||||
let stewardship = stewardship_sample();
|
||||
assert_eq!(stewardship.repo, OrgRepo("GameCult/Epiphany".into()));
|
||||
let key = pipeline_key(&PipelineDocument::Stewardship(stewardship.clone()));
|
||||
assert_eq!(key, Ok(format!("{INSTANCE}:stewardship:GameCult_Epiphany")));
|
||||
assert_eq!(key, Ok(format!("{INSTANCE}:stewardship:GameCult_-Epiphany")));
|
||||
assert!(!key.unwrap().contains('/'), "no key segment carries a slash");
|
||||
|
||||
// The escape is not a cosmetic substitution: two repos that differ only
|
||||
// by the escaped byte still key apart.
|
||||
let mut underscored = stewardship.clone();
|
||||
underscored.repo = OrgRepo("GameCult_Epiphany/thing".into());
|
||||
// The escape is not a cosmetic substitution, and the pair that proves it
|
||||
// is a pair: under `/` -> `_` alone both of these key to
|
||||
// `GameCult_Epiphany_thing`, and one of the two documents is lost.
|
||||
let keyed = |repo: &str| {
|
||||
let mut value = stewardship.clone();
|
||||
value.repo = OrgRepo(repo.into());
|
||||
pipeline_key(&PipelineDocument::Stewardship(value))
|
||||
};
|
||||
let underscored_org = keyed("GameCult_Epiphany/thing");
|
||||
let underscored_repo = keyed("GameCult/Epiphany_thing");
|
||||
assert_eq!(
|
||||
pipeline_key(&PipelineDocument::Stewardship(underscored)),
|
||||
Ok(format!("{INSTANCE}:stewardship:GameCult_Epiphany_thing"))
|
||||
underscored_org,
|
||||
Ok(format!("{INSTANCE}:stewardship:GameCult__Epiphany_-thing"))
|
||||
);
|
||||
assert_eq!(
|
||||
underscored_repo,
|
||||
Ok(format!("{INSTANCE}:stewardship:GameCult_-Epiphany__thing"))
|
||||
);
|
||||
assert_ne!(underscored_org, underscored_repo, "two repos cannot claim one key");
|
||||
|
||||
let mut no_org = stewardship.clone();
|
||||
no_org.repo = OrgRepo("Epiphany".into());
|
||||
|
|
@ -1192,7 +1211,7 @@ mod tests {
|
|||
fn hand_off_names_both_instances() {
|
||||
let key = |hand_off: PipelineHandOff| pipeline_key(&PipelineDocument::HandOff(hand_off));
|
||||
let base = key(hand_off_sample()).expect("the sample keys");
|
||||
assert_eq!(base, format!("{INSTANCE}:hand_off:thought-cage.GameCult_Epiphany.2026-09-15"));
|
||||
assert_eq!(base, format!("{INSTANCE}:hand_off:thought-cage.GameCult_-Epiphany.2026-09-15"));
|
||||
|
||||
let mut other_sender = hand_off_sample();
|
||||
other_sender.from_instance = slug("thought-cage");
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
# `epiphany-core/src/pipeline_documents.rs` to `epiphany-pipeline/src/lib.rs`,
|
||||
# so every entry names its own package as well as its own file. M10 still
|
||||
# mutates the spine, which kept its rule and its test. M11 and M12 are new with
|
||||
# the three kinds this cut added.
|
||||
# the three kinds this cut added, and M13-M15 came out of Soul's pass on them.
|
||||
#
|
||||
# A stale anchor is a hard failure, not a skipped entry: the check below asserts
|
||||
# each anchor matches exactly once, since `.Replace` hits every match and a
|
||||
|
|
@ -173,10 +173,22 @@ fn runtime_spine_schema_cache() -> Result<CultCache> {
|
|||
Id = 'M12'
|
||||
Package = $pipeline
|
||||
File = $documents
|
||||
Rule = 'A repo inside a key is escaped to Org_Repo.'
|
||||
Rule = 'A repo inside a key is escaped.'
|
||||
Test = 'tests::stewardship_key_escapes_the_repo_slash'
|
||||
Old = ' Ok(repo.0.replace(''/'', "_"))'
|
||||
Old = ' Ok(repo.0.replace(''_'', "__").replace(''/'', "_-"))'
|
||||
New = ' Ok(repo.0.clone())'
|
||||
},
|
||||
# Soul F1. Escaping is not enough on its own: the escape has to be
|
||||
# injective, or two repos claim one key and a mind stewarding both silently
|
||||
# loses a document. This is the escape the cut shipped, which is not.
|
||||
@{
|
||||
Id = 'M13'
|
||||
Package = $pipeline
|
||||
File = $documents
|
||||
Rule = 'The repo escape is injective, so two repos cannot claim one key.'
|
||||
Test = 'tests::stewardship_key_escapes_the_repo_slash'
|
||||
Old = ' Ok(repo.0.replace(''_'', "__").replace(''/'', "_-"))'
|
||||
New = ' Ok(repo.0.replace(''/'', "_"))'
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Reference in a new issue