commit ee542a3333fee6e8a0c801777a547d78e182eba1
parent cb8fcfe1e058141220df6e3d33da4b81527595a7
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Jul 2026 20:46:36 +0000
workspace: enforce snake case crate identities
- reject kebab-case Cargo package names and crate directories
- reject kebab-case Radroots dependency keys in every manifest section
- correct residual snake-case examples in the release specification
- preserve synchronized specification integrity hashes
Diffstat:
3 files changed, 165 insertions(+), 9 deletions(-)
diff --git a/docs/specs/radroots_crates_release_v1.md b/docs/specs/radroots_crates_release_v1.md
@@ -75,8 +75,8 @@ ordinary-user façade
The final review makes the following deliberate changes:
-1. **`radroots-contracts` becomes `radroots_protocol`.** The package is a durable, versioned wire/operation protocol boundary rather than a general-purpose “contracts” bucket.
-2. **`radroots-store` and `radroots-store-sqlite` become `radroots_storage` and `radroots_storage_sqlite`.** “Storage” is unambiguous in an agricultural marketplace and describes the package family more accurately than “store.”
+1. **`radroots_contracts` becomes `radroots_protocol`.** The package is a durable, versioned wire/operation protocol boundary rather than a general-purpose “contracts” bucket.
+2. **`radroots_store` and `radroots_store_sqlite` become `radroots_storage` and `radroots_storage_sqlite`.** “Storage” is unambiguous in an agricultural marketplace and describes the package family more accurately than “store.”
3. **`radroots_nostr_connect` remains independent.** It is a bidirectional security protocol with URIs, permissions, client/server state, and independent SDK/Myc consumers. It is not merely a convenience NIP module.
4. **Actor ownership is refined.** Public keys/accounts live in identity; event author roles live in the event contract model; actor provenance, authorization, and signer behavior live in signing.
5. **Trade identity is made singular.** The conflicting `TradeId`/`OrderId` definitions MUST be replaced by one canonical protocol `TradeId` and a separately named business `OrderId`.
@@ -1194,7 +1194,7 @@ Private preview code remains tested. It may become public only after passing the
## 19. New-package admission rule
-After release V1, a new `radroots-*` package requires an ADR proving:
+After release V1, a new `radroots_*` package requires an ADR proving:
1. a durable domain/protocol/SPI/backend boundary;
2. at least two meaningful direct consumers, or one unavoidable platform/backend isolation boundary;
@@ -1229,7 +1229,7 @@ Names containing `common`, `utils`, `types`, `models`, `preview`, `unstable`, `v
| `radroots_nostr_accounts` | `radroots_identity + radroots_secrets + radroots_storage + radroots_sdk` | Split mixed account, vault, persistence, and manager responsibilities. |
| `radroots_nostr_signer` | `radroots_signing + radroots_nostr_connect + Myc-private state` | Do not publish current service-state package. |
| `radroots_nostr_runtime` | `radroots_transport_nostr + radroots_sync` | Merge live relay runtime into adapter/orchestration layers. |
-| `radroots_nostrdb` | `private; possible future radroots-storage-nostrdb` | Withhold until the storage SPI and external consumers justify a backend package. |
+| `radroots_nostrdb` | `private; possible future radroots_storage_nostrdb` | Withhold until the storage SPI and external consumers justify a backend package. |
| `radroots_event_store` | `radroots_storage + radroots_storage_sqlite` | Split backend-neutral contracts from SQLite implementation. |
| `radroots_outbox` | `radroots_storage + radroots_storage_sqlite` | Merge as one persistence capability with atomic operation commits. |
| `radroots_runtime_store` | `radroots_storage or host-private state` | Retire broad name and classify each table by owner. |
@@ -1280,7 +1280,7 @@ Every public package MUST define:
```toml
[package]
-name = "radroots-..."
+name = "radroots_..."
version = "0.1.0"
publish = ["crates-io"]
edition.workspace = true
@@ -1289,7 +1289,7 @@ license.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "README.md"
-documentation = "https://docs.rs/radroots-..."
+documentation = "https://docs.rs/radroots_..."
```
Additional rules:
@@ -1467,7 +1467,7 @@ Rejected because temporary implementation boundaries would become permanent pack
Rejected because domain, protocol, SPI, backend, and adapter packages have independent consumers and semver responsibilities.
-### One `radroots-runtime` package
+### One `radroots_runtime` package
Rejected because runtime configuration, process lifecycle, paths, logging, queues, storage, and networking are not one coherent library boundary.
diff --git a/docs/specs/radroots_crates_release_v1.sha256 b/docs/specs/radroots_crates_release_v1.sha256
@@ -1,4 +1,4 @@
-05d550f393f085b6cbaa0817e289e2487c0ed0861c7979c9225d9dc71f1ca6d4 radroots_crates_release_v1.md
+9e60ffacc6bcaeb8ad9095206fb76efc53f82f0962b3bd5c5e6a4021f4eb3161 radroots_crates_release_v1.md
30d56267f33647524b3093051a4504afe7f330ab8700d7db09985af5b981b62e radroots_crates_release_v1.toml
479f4a09dcad343161c9c644cbde18919eb4365cc295c296ed8ea1458e213882 radroots_crates_release_v1_inventory.csv
d47de10be596a4d33fee102a4f0617f66700b49515a75a1f42d62c9710043059 radroots_crates_release_v1.dot
diff --git a/tools/xtask/src/architecture.rs b/tools/xtask/src/architecture.rs
@@ -812,6 +812,10 @@ fn validate_workspace_members(workspace_root: &Path) -> Result<(), String> {
.map_err(|error| format!("read {}: {error}", manifest_path.display()))?;
let manifest = toml::from_str::<WorkspaceMembershipManifest>(&manifest_raw)
.map_err(|error| format!("parse {}: {error}", manifest_path.display()))?;
+ let manifest_value = manifest_raw
+ .parse::<toml::Value>()
+ .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?;
+ validate_manifest_dependency_keys(&manifest_value, "Cargo.toml")?;
let declared = manifest
.workspace
.members
@@ -831,7 +835,42 @@ fn validate_workspace_members(workspace_root: &Path) -> Result<(), String> {
.is_dir()
&& entry.path().join("Cargo.toml").is_file()
{
- discovered.insert(format!("{root}/{}", entry.file_name().to_string_lossy()));
+ let directory_name = entry.file_name().to_string_lossy().into_owned();
+ require_lower_snake_case(
+ &directory_name,
+ &format!("{root}/{directory_name} crate directory"),
+ )?;
+ let package_manifest_path = entry.path().join("Cargo.toml");
+ let package_manifest_raw =
+ fs::read_to_string(&package_manifest_path).map_err(|error| {
+ format!("read {}: {error}", package_manifest_path.display())
+ })?;
+ let package_manifest =
+ package_manifest_raw
+ .parse::<toml::Value>()
+ .map_err(|error| {
+ format!("parse {}: {error}", package_manifest_path.display())
+ })?;
+ let package_name = package_manifest
+ .get("package")
+ .and_then(toml::Value::as_table)
+ .and_then(|package| package.get("name"))
+ .and_then(toml::Value::as_str)
+ .ok_or_else(|| {
+ format!(
+ "{} is missing package.name",
+ package_manifest_path.display()
+ )
+ })?;
+ require_lower_snake_case(
+ package_name,
+ &format!("{} package.name", package_manifest_path.display()),
+ )?;
+ validate_manifest_dependency_keys(
+ &package_manifest,
+ &package_manifest_path.display().to_string(),
+ )?;
+ discovered.insert(format!("{root}/{directory_name}"));
}
}
}
@@ -853,6 +892,79 @@ fn validate_workspace_members(workspace_root: &Path) -> Result<(), String> {
Ok(())
}
+fn validate_manifest_dependency_keys(manifest: &toml::Value, label: &str) -> Result<(), String> {
+ let Some(manifest) = manifest.as_table() else {
+ return Err(format!("{label} must be a TOML table"));
+ };
+ for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
+ if let Some(dependencies) = manifest.get(section).and_then(toml::Value::as_table) {
+ validate_dependency_keys(dependencies, label, section)?;
+ }
+ }
+ if let Some(workspace_dependencies) = manifest
+ .get("workspace")
+ .and_then(toml::Value::as_table)
+ .and_then(|workspace| workspace.get("dependencies"))
+ .and_then(toml::Value::as_table)
+ {
+ validate_dependency_keys(workspace_dependencies, label, "workspace.dependencies")?;
+ }
+ if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) {
+ for (target, target_value) in targets {
+ let Some(target_table) = target_value.as_table() else {
+ continue;
+ };
+ for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
+ if let Some(dependencies) =
+ target_table.get(section).and_then(toml::Value::as_table)
+ {
+ validate_dependency_keys(
+ dependencies,
+ label,
+ &format!("target.{target}.{section}"),
+ )?;
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+fn validate_dependency_keys(
+ dependencies: &toml::value::Table,
+ label: &str,
+ section: &str,
+) -> Result<(), String> {
+ for key in dependencies
+ .keys()
+ .filter(|key| key.starts_with("radroots"))
+ {
+ require_lower_snake_case(key, &format!("{label} {section} dependency key"))?;
+ }
+ Ok(())
+}
+
+fn require_lower_snake_case(value: &str, label: &str) -> Result<(), String> {
+ if is_lower_snake_case(value) {
+ Ok(())
+ } else {
+ Err(format!("{label} `{value}` must use lowercase snake case"))
+ }
+}
+
+fn is_lower_snake_case(value: &str) -> bool {
+ !value.is_empty()
+ && value.split('_').all(|segment| {
+ segment
+ .as_bytes()
+ .first()
+ .is_some_and(u8::is_ascii_lowercase)
+ && segment
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
+ })
+}
+
fn validate_ledger(
workspace_root: &Path,
expected_architecture_id: &str,
@@ -1127,6 +1239,50 @@ adr_required = false
}
#[test]
+ fn workspace_identity_rejects_kebab_case_crate_names_and_dependency_keys() {
+ let root = test_root("workspace_snake_case_identity");
+ fs::create_dir_all(root.join("crates/radroots_probe")).expect("create package root");
+ fs::create_dir_all(root.join("tools/xtask")).expect("create xtask root");
+ fs::write(
+ root.join("Cargo.toml"),
+ "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n",
+ )
+ .expect("write workspace manifest");
+ fs::write(
+ root.join("crates/radroots_probe/Cargo.toml"),
+ "[package]\nname = \"radroots-probe\"\n",
+ )
+ .expect("write kebab package manifest");
+ fs::write(
+ root.join("tools/xtask/Cargo.toml"),
+ "[package]\nname = \"xtask\"\n",
+ )
+ .expect("write xtask manifest");
+
+ let package_error = validate_workspace_members(&root)
+ .expect_err("kebab-case Cargo package identity must fail");
+ assert!(package_error.contains("package.name"));
+ assert!(package_error.contains("lowercase snake case"));
+
+ fs::write(
+ root.join("crates/radroots_probe/Cargo.toml"),
+ "[package]\nname = \"radroots_probe\"\n",
+ )
+ .expect("write snake package manifest");
+ fs::write(
+ root.join("Cargo.toml"),
+ "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.dependencies]\nradroots-probe = \"0.1.0\"\n",
+ )
+ .expect("write kebab dependency key");
+
+ let dependency_error = validate_workspace_members(&root)
+ .expect_err("kebab-case Radroots dependency key must fail");
+ assert!(dependency_error.contains("workspace.dependencies dependency key"));
+ assert!(dependency_error.contains("lowercase snake case"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
fn workspace_toolchain_requires_exact_resolver_and_rust_version() {
let root = test_root("workspace_toolchain");
fs::write(root.join("Cargo.toml"), complete_workspace_manifest(""))