sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit a00c36a1b2d5ed269878b954041331e328b92d55
parent a7e63f6ccea6fdc60047d583c54e816a95576366
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 20:49:23 +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 smoke, preview-gate, and normative package identities
- preserve synchronized specification integrity hashes

Diffstat:
Mdocs/specs/radroots_crates_release_v1.md | 14+++++++-------
Mdocs/specs/radroots_crates_release_v1.sha256 | 2+-
Mtools/xtask/src/architecture.rs | 158++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtools/xtask/src/check.rs | 16++++++----------
Mtools/xtask/src/smoke.rs | 3++-
5 files changed, 173 insertions(+), 20 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 @@ -810,6 +810,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 @@ -829,7 +833,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}")); } } } @@ -851,6 +890,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, @@ -1125,6 +1237,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("")) diff --git a/tools/xtask/src/check.rs b/tools/xtask/src/check.rs @@ -29,7 +29,7 @@ const PACKAGE_LICENSE: &str = "MIT OR Apache-2.0"; const PACKAGE_HOMEPAGE: &str = "https://radroots.org"; const PACKAGE_REPOSITORY_URL: &str = "git+https://github.com/radrootslabs/sdk.git"; const PUBLISH_ACCESS: &str = "public"; -const ALLOWED_RETICULUM_PREVIEW_PACKAGE: &str = "radroots-transport-reticulum"; +const ALLOWED_RETICULUM_PREVIEW_PACKAGE: &str = "radroots_transport_reticulum"; const RADROOTS_FACADE_MODULES: [&str; 11] = [ "client", "event", @@ -730,8 +730,7 @@ fn check_reticulum_feature_entry( .map_or((feature_dependency, None), |(name, feature)| { (name, Some(feature)) }); - let normalized_dependency = normalize_package_name(dependency_name); - if normalized_dependency == ALLOWED_RETICULUM_PREVIEW_PACKAGE && dependency_feature.is_some() { + if dependency_name == ALLOWED_RETICULUM_PREVIEW_PACKAGE && dependency_feature.is_some() { return Err(format!( "{manifest_label} feature `{feature_name}` must not enable Reticulum preview crate features through `{entry}`" )); @@ -796,10 +795,10 @@ fn reject_forbidden_reticulum_runtime_name( } fn is_forbidden_reticulum_runtime_name(name: &str) -> bool { - let normalized = normalize_package_name(name); - if normalized == ALLOWED_RETICULUM_PREVIEW_PACKAGE { + if name == ALLOWED_RETICULUM_PREVIEW_PACKAGE { return false; } + let normalized = normalize_package_name(name); matches!( normalized.as_str(), "rns" | "rnsd" | "reticulum" | "reticulum-rs" | "python" | "pyo3" @@ -2442,17 +2441,14 @@ transport-reticulum-preview = [] "python-runtime", "pyo3", "pyo3-ffi", + "radroots-transport-reticulum", ] { assert!( is_forbidden_reticulum_runtime_name(name), "{name} must be forbidden" ); } - for name in [ - "radroots_transport_reticulum", - "radroots-transport-reticulum", - "dto_bindgen_backend_python", - ] { + for name in ["radroots_transport_reticulum", "dto_bindgen_backend_python"] { assert!( !is_forbidden_reticulum_runtime_name(name), "{name} must be allowed" diff --git a/tools/xtask/src/smoke.rs b/tools/xtask/src/smoke.rs @@ -60,7 +60,7 @@ fn render_consumer_manifest(sdk_path: &Path, nostr_version: &str) -> Result<Stri .map_err(|error| format!("failed to render SDK path: {error}"))?; Ok(format!( r#"[package] -name = "radroots-sdk-knowledge-smoke" +name = "radroots_sdk_knowledge_smoke" version = "0.1.0" edition = "2024" publish = false @@ -220,6 +220,7 @@ mod tests { let manifest = render_consumer_manifest(Path::new("/tmp/radroots_sdk"), "=0.44.2").expect("manifest"); + assert!(manifest.contains("name = \"radroots_sdk_knowledge_smoke\"")); assert!(manifest.contains("nostr = \"=0.44.2\"")); assert!(!manifest.contains("nostr = \"0.44.2\"")); }