Share workflow packaging results and validation across callers

This commit is contained in:
Scott Werner 2026-09-12 10:14:53 -06:00
parent 410651d781
commit ec81170c8a
16 changed files with 184 additions and 215 deletions

1
Cargo.lock generated
View file

@ -3173,6 +3173,7 @@ dependencies = [
"fabro-client",
"fabro-types",
"fabro-util",
"fabro-workflow-version",
"futures",
"httpmock",
"schemars 1.2.1",

View file

@ -1858,7 +1858,7 @@ async fn mcp_workflow_version_validation_happens_before_auth_or_network() {
serde_json::json!({"entrypoint":"workflow","files":{}}),
)
.await;
assert_eq!(error, "entrypoint must be an exact supplied file key");
assert_eq!(error, "entrypoint `workflow` is not present in workflow files");
assert_mcp_run_tool_count(&client).await;
client
.shutdown()

View file

@ -5,6 +5,8 @@
mod local_workflow_package;
mod supplied_workflow;
#[cfg(test)]
mod test_support;
mod workflow_bundler;
mod workflow_version_collector;
mod workflow_version_packager;
@ -32,6 +34,7 @@ use fabro_types::{
WorkflowSettings,
};
use fabro_workflow::git::{self, GitSyncStatus};
pub use fabro_workflow_version::CollectedWorkflowClosure;
pub use crate::local_workflow_package::{
LocalWorkflowPackageError, ResolvedLocalWorkflowPackage, resolve_local_workflow_package,
@ -39,8 +42,8 @@ pub use crate::local_workflow_package::{
pub use crate::supplied_workflow::collect_supplied_workflow_versions;
use crate::workflow_bundler::WorkflowBundler;
pub use crate::workflow_version_collector::{
CollectedWorkflowClosure, MAX_WORKFLOW_VERSION_DEPTH, WorkflowVersionCollectError,
collect_workflow_versions, collect_workflow_versions_at_location,
MAX_WORKFLOW_VERSION_DEPTH, WorkflowVersionCollectError, collect_workflow_versions,
collect_workflow_versions_at_location,
};
pub use crate::workflow_version_packager::SuppliedWorkflowVersionPackager;

View file

@ -118,42 +118,11 @@ fn confine_to_supplied(
mod tests {
use std::path::Path;
use fabro_tool::ValidatedWorkflowVersionCreate as Supplied;
use fabro_util::error::collect_chain;
use super::*;
struct Supplied {
entrypoint: WorkflowPath,
files: BTreeMap<WorkflowPath, String>,
}
fn supplied(entrypoint: &str, files: &[(&str, &str)]) -> Supplied {
Supplied {
entrypoint: entrypoint.parse().unwrap(),
files: files
.iter()
.map(|(path, content)| (path.parse().unwrap(), (*content).to_string()))
.collect(),
}
}
fn fixture() -> Supplied {
supplied("workflow.toml", &[
(
"workflow.toml",
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
),
(
"workflow.fabro",
r#"digraph W { p [prompt="@prompt.md"] child [stack.child_workflow="child.fabro"] }"#,
),
(
"prompt.md",
"Keep {{ secrets.TEST }} and {{ env.TEST }} for runtime.",
),
("child.fabro", "digraph Child {}"),
])
}
use crate::test_support::{fixture, source as supplied};
fn collect(input: &Supplied) -> CollectedWorkflowClosure {
collect_supplied_workflow_versions(&input.entrypoint, &input.files).unwrap()

View file

@ -0,0 +1,29 @@
use fabro_tool::ValidatedWorkflowVersionCreate;
pub(super) fn source(entrypoint: &str, files: &[(&str, &str)]) -> ValidatedWorkflowVersionCreate {
ValidatedWorkflowVersionCreate {
entrypoint: entrypoint.parse().unwrap(),
files: files
.iter()
.map(|(path, content)| (path.parse().unwrap(), (*content).to_string()))
.collect(),
}
}
pub(super) fn fixture() -> ValidatedWorkflowVersionCreate {
source("workflow.toml", &[
(
"workflow.toml",
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
),
(
"workflow.fabro",
r#"digraph W { p [prompt="@prompt.md"] child [stack.child_workflow="child.fabro"] }"#,
),
(
"prompt.md",
"Keep {{ secrets.TEST }} and {{ env.TEST }} for runtime.",
),
("child.fabro", "digraph Child {}"),
])
}

View file

@ -17,7 +17,10 @@ use fabro_template::{
use fabro_types::ManifestPath;
use fabro_types::graph::ReferenceKind;
use crate::{manifest_path_from_absolute, normalize_absolute_path, workflow_version_collector};
use crate::{
WorkflowVersionCollectError, manifest_path_from_absolute, normalize_absolute_path,
workflow_version_collector,
};
pub(super) struct WorkflowBundler<'a> {
package_root: &'a Path,
@ -38,15 +41,6 @@ pub(super) struct CollectedWorkflowSource {
pub(super) dependency_keys: BTreeSet<String>,
}
/// A referenced file that does not exist under the package root, reported
/// with its package-relative path so callers can surface it without the
/// staging directory or any file content.
#[derive(Debug, thiserror::Error)]
#[error("workflow package file `{path}` is missing")]
pub(super) struct MissingPackageFile {
pub(super) path: String,
}
impl<'a> WorkflowBundler<'a> {
pub(super) fn new(package_root: &'a Path, inputs: &'a HashMap<String, toml::Value>) -> Self {
Self {
@ -546,7 +540,9 @@ impl<'a> WorkflowBundler<'a> {
if source.kind() == std::io::ErrorKind::NotFound {
let path = ManifestPath::from_absolute(path, self.package_root)
.map_or_else(|| path.display().to_string(), |path| path.to_string());
return anyhow::Error::new(MissingPackageFile { path });
return anyhow::Error::new(WorkflowVersionCollectError::MissingPackageFile {
path,
});
}
anyhow::Error::new(source).context(format!(
"failed to canonicalize workflow package file `{}`",

View file

@ -7,12 +7,12 @@ use fabro_types::{
WorkflowPath, WorkflowPathParseError, WorkflowVersion, WorkflowVersionId,
WorkflowVersionShapeError,
};
use fabro_workflow_version::{ValidatedWorkflowVersion, WorkflowVersionError};
use fabro_workflow_version::{
CollectedWorkflowClosure, ValidatedWorkflowVersion, WorkflowVersionError,
};
use thiserror::Error;
use crate::workflow_bundler::{
CollectedWorkflowSource, CollectedWorkflowSources, MissingPackageFile, WorkflowBundler,
};
use crate::workflow_bundler::{CollectedWorkflowSource, CollectedWorkflowSources, WorkflowBundler};
/// Maximum active graph nesting while collecting or assembling a version.
/// Bounds native stack use independently of file-count and byte budgets.
@ -31,37 +31,6 @@ pub(super) fn check_workflow_depth(
Ok(())
}
/// One locally packaged workflow-version closure in dependency-first order.
#[derive(Debug)]
pub struct CollectedWorkflowClosure {
root_id: WorkflowVersionId,
versions: Vec<(WorkflowVersionId, ValidatedWorkflowVersion)>,
}
impl CollectedWorkflowClosure {
#[must_use]
pub fn root_id(&self) -> WorkflowVersionId {
self.root_id
}
/// Iterate over every unique version with dependencies before parents.
pub fn versions(
&self,
) -> impl Iterator<Item = (WorkflowVersionId, &ValidatedWorkflowVersion)> + '_ {
self.versions.iter().map(|(id, version)| (*id, version))
}
/// Consume the closure, yielding every version with dependencies before
/// parents, for callers that hand the versions on without cloning.
#[must_use]
pub fn into_versions(self) -> Vec<WorkflowVersion> {
self.versions
.into_iter()
.map(|(_, version)| version.into_version())
.collect()
}
}
#[derive(Debug, Error)]
pub enum WorkflowVersionCollectError {
#[error("workflow dependency nesting at `{path}` exceeds {maximum} levels")]
@ -207,22 +176,12 @@ pub fn collect_workflow_versions_at_location(
let collected = WorkflowBundler::new(package_root, &inputs)
.collect_versions(location)
.map_err(|source| {
let source = match source.downcast::<WorkflowVersionCollectError>() {
Ok(error) => return error,
Err(source) => source,
};
let missing = source
.chain()
.find_map(|cause| cause.downcast_ref::<MissingPackageFile>());
match missing {
Some(missing) => WorkflowVersionCollectError::MissingPackageFile {
path: missing.path.clone(),
},
None => WorkflowVersionCollectError::Collect {
source
.downcast::<WorkflowVersionCollectError>()
.unwrap_or_else(|source| WorkflowVersionCollectError::Collect {
path: workflow.to_path_buf(),
source,
},
}
})
})?;
VersionAssembler::new(collected).assemble()
}
@ -260,10 +219,10 @@ impl VersionAssembler {
fn assemble(mut self) -> Result<CollectedWorkflowClosure, WorkflowVersionCollectError> {
let root_key = std::mem::take(&mut self.root_key);
let root_id = self.assemble_one(&root_key)?;
Ok(CollectedWorkflowClosure {
Ok(CollectedWorkflowClosure::from_dependency_order(
root_id,
versions: self.versions,
})
self.versions,
))
}
fn assemble_one(

View file

@ -3,11 +3,9 @@
use anyhow::Context as _;
use async_trait::async_trait;
use fabro_tool::{
PackagedWorkflowVersions, ToolError, ValidatedWorkflowVersionCreate, WorkflowVersionPackager,
};
use fabro_tool::{ToolError, ValidatedWorkflowVersionCreate, WorkflowVersionPackager};
use fabro_util::error::collect_chain;
use fabro_workflow_version::WorkflowVersionError;
use fabro_workflow_version::{CollectedWorkflowClosure, WorkflowVersionError};
use tokio::task;
use tracing::debug;
@ -24,7 +22,7 @@ impl WorkflowVersionPackager for SuppliedWorkflowVersionPackager {
async fn package(
&self,
source: ValidatedWorkflowVersionCreate,
) -> anyhow::Result<PackagedWorkflowVersions> {
) -> anyhow::Result<CollectedWorkflowClosure> {
let packaged = task::spawn_blocking(move || package_blocking(&source))
.await
.context("workflow packaging task failed")??;
@ -40,7 +38,7 @@ impl WorkflowVersionPackager for SuppliedWorkflowVersionPackager {
/// must not reach the log at any level.
fn package_blocking(
source: &ValidatedWorkflowVersionCreate,
) -> Result<PackagedWorkflowVersions, ToolError> {
) -> Result<CollectedWorkflowClosure, ToolError> {
let closure = crate::collect_supplied_workflow_versions(&source.entrypoint, &source.files)
.map_err(|err| {
debug!(
@ -51,10 +49,7 @@ fn package_blocking(
);
ToolError::message(render_packaging_error(&err))
})?;
Ok(PackagedWorkflowVersions {
root_id: closure.root_id(),
versions: closure.into_versions(),
})
Ok(closure)
}
/// Render a packaging failure for the tool caller. Every collector variant's
@ -120,30 +115,7 @@ mod tests {
}
}
fn source(entrypoint: &str, files: &[(&str, &str)]) -> ValidatedWorkflowVersionCreate {
ValidatedWorkflowVersionCreate {
entrypoint: entrypoint.parse().unwrap(),
files: files
.iter()
.map(|(path, content)| (path.parse().unwrap(), (*content).to_string()))
.collect(),
}
}
fn fixture() -> ValidatedWorkflowVersionCreate {
source("workflow.toml", &[
(
"workflow.toml",
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
),
(
"workflow.fabro",
r#"digraph W { p [prompt="@prompt.md"] child [stack.child_workflow="child.fabro"] }"#,
),
("prompt.md", "Review the implementation."),
("child.fabro", "digraph Child {}"),
])
}
use crate::test_support::{fixture, source};
async fn package_error(input: ValidatedWorkflowVersionCreate) -> String {
let error = SuppliedWorkflowVersionPackager
@ -246,16 +218,20 @@ mod tests {
.package(fixture())
.await
.unwrap();
assert_eq!(packaged.versions.len(), 2);
assert_eq!(packaged.versions[0].entrypoint().as_str(), "child.fabro");
let versions = packaged
.versions()
.map(|(_, v)| v.version())
.collect::<Vec<_>>();
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].entrypoint().as_str(), "child.fabro");
assert_eq!(
packaged.versions[1].id().unwrap(),
packaged.root_id,
versions[1].id().unwrap(),
packaged.root_id(),
"root version must be last"
);
let child_id = packaged.versions[0].id().unwrap();
let child_id = versions[0].id().unwrap();
assert!(
packaged.versions[1]
versions[1]
.workflow_dependencies()
.values()
.any(|id| *id == child_id)

View file

@ -20,6 +20,7 @@ fabro-api = { path = "../../foundation/fabro-api" }
fabro-client = { path = "../../foundation/fabro-client" }
fabro-types = { path = "../../foundation/fabro-types" }
fabro-util = { path = "../../foundation/fabro-util" }
fabro-workflow-version = { path = "../fabro-workflow-version" }
futures.workspace = true
schemars = "1.2.1"
serde.workspace = true

View file

@ -82,10 +82,12 @@ impl FabroToolBackend for ClientBackend {
.as_ref()
.ok_or_else(common::workflow_version_tool_unavailable_error)?;
let packaged = packager.package(source).await?;
self.client
.register_workflow_versions(&packaged.versions)
.await?;
Ok(packaged.root_id)
let versions = packaged
.versions()
.map(|(_, v)| v.version())
.collect::<Vec<_>>();
self.client.register_workflow_versions(versions).await?;
Ok(packaged.root_id())
}
async fn create_run_from_spec(
@ -292,22 +294,29 @@ mod tests {
use async_trait::async_trait;
use fabro_types::{WorkflowVersion, WorkflowVersionId};
use fabro_workflow_version::{CollectedWorkflowClosure, ValidatedWorkflowVersion};
use serde_json::json;
use super::*;
use crate::{
PackagedWorkflowVersions, ValidatedWorkflowVersionCreate, WorkflowVersionPackager,
};
use crate::{ValidatedWorkflowVersionCreate, WorkflowVersionPackager};
struct FixedPackager(PackagedWorkflowVersions);
struct FixedPackager(Vec<WorkflowVersion>);
#[async_trait]
impl WorkflowVersionPackager for FixedPackager {
async fn package(
&self,
_: ValidatedWorkflowVersionCreate,
) -> anyhow::Result<PackagedWorkflowVersions> {
Ok(self.0.clone())
) -> anyhow::Result<CollectedWorkflowClosure> {
let versions = self
.0
.iter()
.map(|v| Ok((v.id()?, ValidatedWorkflowVersion::new(v.clone())?)))
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(CollectedWorkflowClosure::from_dependency_order(
versions.last().unwrap().0,
versions,
))
}
}
@ -319,7 +328,14 @@ mod tests {
entrypoint.parse().unwrap(),
BTreeMap::from([(
entrypoint.parse().unwrap(),
format!("digraph {entrypoint} {{}}"),
format!(
"digraph {entrypoint} {{ {} }}",
dependencies
.keys()
.map(|p| format!("child [stack.child_workflow=\"{p}\"]"))
.collect::<Vec<_>>()
.join(" ")
),
)]),
dependencies,
)
@ -361,12 +377,8 @@ mod tests {
})
.await;
let client = ::fabro_client::Client::new_no_proxy(&server.url("")).unwrap();
let backend = ClientBackend::new(Arc::new(client)).with_workflow_version_packager(
Arc::new(FixedPackager(PackagedWorkflowVersions {
root_id,
versions: vec![child, root.clone()],
})),
);
let backend = ClientBackend::new(Arc::new(client))
.with_workflow_version_packager(Arc::new(FixedPackager(vec![child, root.clone()])));
assert!(backend.create_workflow_version(source()).await.is_err());
child_upload.assert_calls_async(1).await;

View file

@ -49,6 +49,6 @@ pub use search::{
search_runs, search_runs_text,
};
pub use workflow_version::{
FabroWorkflowVersionCreateParams, PackagedWorkflowVersions, ValidatedWorkflowVersionCreate,
WorkflowVersionPackager, create_workflow_version, workflow_version_create_text,
FabroWorkflowVersionCreateParams, ValidatedWorkflowVersionCreate, WorkflowVersionPackager,
create_workflow_version, workflow_version_create_text,
};

View file

@ -3,10 +3,8 @@ use std::sync::Arc;
use async_trait::async_trait;
use fabro_api::types::CreateWorkflowVersionResponse;
use fabro_types::{
MAX_WORKFLOW_VERSION_BYTES, MAX_WORKFLOW_VERSION_FILE_BYTES, MAX_WORKFLOW_VERSION_FILES,
WorkflowPath, WorkflowVersion, WorkflowVersionId,
};
use fabro_types::{MAX_WORKFLOW_VERSION_BYTES, WorkflowPath};
use fabro_workflow_version::CollectedWorkflowClosure;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -41,26 +39,9 @@ impl TryFrom<FabroWorkflowVersionCreateParams> for ValidatedWorkflowVersionCreat
fn try_from(params: FabroWorkflowVersionCreateParams) -> Result<Self, Self::Error> {
let FabroWorkflowVersionCreateParams { entrypoint, files } = params;
if !files.contains_key(&entrypoint) {
return Err(ToolError::message(
"entrypoint must be an exact supplied file key",
));
}
if files.len() > MAX_WORKFLOW_VERSION_FILES {
return Err(ToolError::message(format!(
"workflow source exceeds {MAX_WORKFLOW_VERSION_FILES} files"
)));
}
let mut total = 0;
for content in files.values() {
if content.len() > MAX_WORKFLOW_VERSION_FILE_BYTES {
return Err(ToolError::message(format!(
"workflow source file exceeds {} KiB",
MAX_WORKFLOW_VERSION_FILE_BYTES / 1024
)));
}
total += content.len();
}
fabro_types::validate_workflow_files(&entrypoint, &files)
.map_err(|err| ToolError::message(err.to_string()))?;
let total: usize = files.values().map(String::len).sum();
if total > MAX_WORKFLOW_VERSION_BYTES {
return Err(ToolError::message(format!(
"workflow source exceeds {} MiB",
@ -73,15 +54,6 @@ impl TryFrom<FabroWorkflowVersionCreateParams> for ValidatedWorkflowVersionCreat
}
}
/// The complete validated closure for one supplied source tree.
#[derive(Clone, Debug)]
pub struct PackagedWorkflowVersions {
pub root_id: WorkflowVersionId,
/// Every version in the closure, dependencies before the versions that
/// reference them, so callers can register them in this order.
pub versions: Vec<WorkflowVersion>,
}
/// Application seam for packaging supplied content. The manifest crates that
/// own collection depend on this crate, so the packager is injected instead.
/// Implementations confine reads to supplied files and validate the entire
@ -91,7 +63,7 @@ pub trait WorkflowVersionPackager: Send + Sync {
async fn package(
&self,
source: ValidatedWorkflowVersionCreate,
) -> anyhow::Result<PackagedWorkflowVersions>;
) -> anyhow::Result<CollectedWorkflowClosure>;
}
pub async fn create_workflow_version(
@ -114,6 +86,7 @@ pub fn workflow_version_create_text(result: &CreateWorkflowVersionResponse) -> S
#[cfg(test)]
mod tests {
use fabro_types::{MAX_WORKFLOW_VERSION_FILE_BYTES, MAX_WORKFLOW_VERSION_FILES};
use serde_json::json;
use super::*;
@ -227,7 +200,7 @@ mod tests {
async fn package(
&self,
_: ValidatedWorkflowVersionCreate,
) -> anyhow::Result<PackagedWorkflowVersions> {
) -> anyhow::Result<CollectedWorkflowClosure> {
panic!("scoped backend must not invoke the packager")
}
}

View file

@ -0,0 +1,38 @@
use fabro_types::WorkflowVersionId;
use crate::ValidatedWorkflowVersion;
/// Validated workflow versions in dependency-first order, with the root last.
/// Owns source contents; consumers borrow versions instead of cloning them.
#[derive(Debug)]
pub struct CollectedWorkflowClosure {
root_id: WorkflowVersionId,
versions: Vec<(WorkflowVersionId, ValidatedWorkflowVersion)>,
}
impl CollectedWorkflowClosure {
/// Assemble the result of a collector that has already ordered and
/// validated the dependency graph. The caller supplies matching IDs,
/// unique versions, and dependencies before parents, with `root_id`
/// identifying the last entry. This preserves the collector's ordering
/// without traversing or hashing again.
#[must_use]
pub fn from_dependency_order(
root_id: WorkflowVersionId,
versions: Vec<(WorkflowVersionId, ValidatedWorkflowVersion)>,
) -> Self {
Self { root_id, versions }
}
#[must_use]
pub fn root_id(&self) -> WorkflowVersionId {
self.root_id
}
/// Iterate over every version with dependencies before parents.
pub fn versions(
&self,
) -> impl Iterator<Item = (WorkflowVersionId, &ValidatedWorkflowVersion)> + '_ {
self.versions.iter().map(|(id, version)| (*id, version))
}
}

View file

@ -23,8 +23,9 @@ use fabro_types::settings::InterpString;
use fabro_types::{ManifestPath, WorkflowPath, WorkflowPathParseError, WorkflowVersion};
use thiserror::Error;
mod closure;
mod store;
pub use closure::CollectedWorkflowClosure;
pub use store::{LoadedWorkflowVersionClosure, WorkflowVersionStore, WorkflowVersionStoreError};
#[derive(Debug, Error)]

View file

@ -198,6 +198,6 @@ pub use workflow_path::{
pub use workflow_version::{
MAX_WORKFLOW_VERSION_BYTES, MAX_WORKFLOW_VERSION_DEPENDENCIES, MAX_WORKFLOW_VERSION_FILE_BYTES,
MAX_WORKFLOW_VERSION_FILES, WorkflowVersion, WorkflowVersionShapeError,
validate_workflow_source_paths,
validate_workflow_files, validate_workflow_source_paths,
};
pub use workflow_version_id::{WorkflowVersionId, WorkflowVersionIdParseError};

View file

@ -122,32 +122,13 @@ impl WorkflowVersion {
}
fn validate_shape(&self) -> Result<(), WorkflowVersionShapeError> {
if self.files.len() > MAX_WORKFLOW_VERSION_FILES {
return Err(WorkflowVersionShapeError::TooManyFiles {
actual: self.files.len(),
maximum: MAX_WORKFLOW_VERSION_FILES,
});
}
validate_workflow_files(&self.entrypoint, &self.files)?;
if self.workflow_dependencies.len() > MAX_WORKFLOW_VERSION_DEPENDENCIES {
return Err(WorkflowVersionShapeError::TooManyWorkflowDependencies {
actual: self.workflow_dependencies.len(),
maximum: MAX_WORKFLOW_VERSION_DEPENDENCIES,
});
}
for (path, content) in &self.files {
if content.len() > MAX_WORKFLOW_VERSION_FILE_BYTES {
return Err(WorkflowVersionShapeError::FileTooLarge {
path: path.clone(),
actual: content.len(),
maximum: MAX_WORKFLOW_VERSION_FILE_BYTES,
});
}
}
if !self.files.contains_key(&self.entrypoint) {
return Err(WorkflowVersionShapeError::MissingEntrypoint {
path: self.entrypoint.clone(),
});
}
self.validate_path_collisions()
}
@ -159,6 +140,36 @@ impl WorkflowVersion {
}
}
/// Validate the file limits and entrypoint shared by source trees and versions.
/// Aggregate source bytes, canonical bytes, and path policies are checked
/// separately.
pub fn validate_workflow_files(
entrypoint: &WorkflowPath,
files: &BTreeMap<WorkflowPath, String>,
) -> Result<(), WorkflowVersionShapeError> {
if files.len() > MAX_WORKFLOW_VERSION_FILES {
return Err(WorkflowVersionShapeError::TooManyFiles {
actual: files.len(),
maximum: MAX_WORKFLOW_VERSION_FILES,
});
}
for (path, content) in files {
if content.len() > MAX_WORKFLOW_VERSION_FILE_BYTES {
return Err(WorkflowVersionShapeError::FileTooLarge {
path: path.clone(),
actual: content.len(),
maximum: MAX_WORKFLOW_VERSION_FILE_BYTES,
});
}
}
if !files.contains_key(entrypoint) {
return Err(WorkflowVersionShapeError::MissingEntrypoint {
path: entrypoint.clone(),
});
}
Ok(())
}
/// Reject file and directory aliases before materializing a portable source
/// tree, including Unicode case folding and normalization. Canonical versions
/// themselves retain their exact, case-sensitive semantics.