commit 62fccb4d203e7a137b3635aa8cfa4143dfc59755
parent 52912c1fe42bcfe38e0b549cd21afa05905581da
Author: triesap <tyson@radroots.org>
Date: Sun, 12 Apr 2026 23:13:27 +0000
spec: align curated typescript sdk package model
Diffstat:
3 files changed, 111 insertions(+), 12 deletions(-)
diff --git a/crates/xtask/src/contract.rs b/crates/xtask/src/contract.rs
@@ -24,6 +24,7 @@ pub struct ContractManifest {
pub contract: ManifestContract,
pub surface: Surface,
pub policy: Policy,
+ pub export: Option<ManifestExports>,
}
#[derive(Debug, Deserialize)]
@@ -48,6 +49,16 @@ pub struct Policy {
}
#[derive(Debug, Deserialize)]
+pub struct ManifestExports {
+ pub ts: Option<ManifestLanguagePackages>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ManifestLanguagePackages {
+ pub packages: Vec<String>,
+}
+
+#[derive(Debug, Deserialize)]
pub struct OperationsContractManifest {
pub contract: ManifestContract,
pub public: PublicContract,
@@ -1722,6 +1733,20 @@ fn collect_non_empty_set(items: &[String], field: &str) -> Result<BTreeSet<Strin
Ok(set)
}
+fn ts_curated_package_set(bundle: &ContractBundle) -> Result<Option<BTreeSet<String>>, String> {
+ let Some(export_targets) = bundle.manifest.export.as_ref() else {
+ return Ok(None);
+ };
+ let Some(ts_export) = export_targets.ts.as_ref() else {
+ return Ok(None);
+ };
+ let packages = collect_non_empty_set(&ts_export.packages, "manifest export.ts.packages")?;
+ if packages.is_empty() {
+ return Err("manifest export.ts.packages must not be empty".to_string());
+ }
+ Ok(Some(packages))
+}
+
fn validate_operations_contract(
bundle: &ContractBundle,
operations_manifest: &OperationsContractManifest,
@@ -1949,6 +1974,7 @@ fn validate_operations_contract(
);
}
+ let ts_packages = ts_curated_package_set(bundle)?;
let mut has_ts_mapping = false;
for mapping in &bundle.sdk_exports {
if mapping.language.id.trim().is_empty() {
@@ -2033,6 +2059,24 @@ fn validate_operations_contract(
.to_string(),
);
}
+ if let Some(expected_packages) = ts_packages.as_ref() {
+ if expected_packages.len() != 1 {
+ return Err(
+ "manifest export.ts.packages must define exactly one curated ts package"
+ .to_string(),
+ );
+ }
+ let expected_package = expected_packages
+ .iter()
+ .next()
+ .expect("single-package ts export set");
+ if mapping.sdk.package != *expected_package {
+ return Err(format!(
+ "sdk export ts package {} must match manifest export.ts.packages {}",
+ mapping.sdk.package, expected_package
+ ));
+ }
+ }
let artifacts = mapping
.artifacts
.as_ref()
@@ -2622,6 +2666,7 @@ fn validate_contract_bundle_with_release_policy_override(
if bundle.exports.is_empty() {
return Err("at least one language export mapping is required".to_string());
}
+ let ts_packages = ts_curated_package_set(bundle)?;
for mapping in &bundle.exports {
if mapping.language.id.trim().is_empty() {
return Err("language.id is required".to_string());
@@ -2662,6 +2707,16 @@ fn validate_contract_bundle_with_release_policy_override(
{
return Err("artifacts fields must be non-empty for ts".to_string());
}
+ if let Some(expected_packages) = ts_packages.as_ref() {
+ let mapped_packages = mapping.packages.values().cloned().collect::<BTreeSet<_>>();
+ if mapped_packages != *expected_packages {
+ return Err(format!(
+ "ts export packages {} must match manifest export.ts.packages {}",
+ join_set(&mapped_packages),
+ join_set(expected_packages)
+ ));
+ }
+ }
}
}
if bundle.version.contract.version.trim().is_empty() {
@@ -3036,6 +3091,7 @@ pub fn validate_contract_bundle(bundle: &ContractBundle) -> Result<(), String> {
if bundle.exports.is_empty() {
return Err("at least one language export mapping is required".to_string());
}
+ let ts_packages = ts_curated_package_set(bundle)?;
for mapping in &bundle.exports {
if mapping.language.id.trim().is_empty() {
return Err("language.id is required".to_string());
@@ -3076,6 +3132,16 @@ pub fn validate_contract_bundle(bundle: &ContractBundle) -> Result<(), String> {
{
return Err("artifacts fields must be non-empty for ts".to_string());
}
+ if let Some(expected_packages) = ts_packages.as_ref() {
+ let mapped_packages = mapping.packages.values().cloned().collect::<BTreeSet<_>>();
+ if mapped_packages != *expected_packages {
+ return Err(format!(
+ "ts export packages {} must match manifest export.ts.packages {}",
+ join_set(&mapped_packages),
+ join_set(expected_packages)
+ ));
+ }
+ }
}
}
if bundle.version.contract.version.trim().is_empty() {
@@ -3222,6 +3288,9 @@ model_crates = ["radroots_a"]
algorithm_crates = ["radroots_b"]
wasm_crates = ["radroots_a_wasm"]
+[export.ts]
+packages = ["@radroots/sdk"]
+
[policy]
exclude_internal_workspace_crates = true
require_reproducible_exports = true
@@ -3252,7 +3321,7 @@ id = "ts"
repository = "sdk-typescript"
[packages]
-"radroots_a" = "@radroots/a"
+"radroots_a" = "@radroots/sdk"
[artifacts]
models_dir = "src/generated"
@@ -3607,9 +3676,11 @@ crates = ["radroots_a"]
let bundle = load_contract_bundle(&root).expect("load contract");
for mapping in &bundle.exports {
if mapping.language.id == "ts" {
- for package in mapping.packages.values() {
- assert!(package.starts_with("@radroots/"));
- }
+ let packages = mapping.packages.values().cloned().collect::<BTreeSet<_>>();
+ assert_eq!(
+ packages,
+ ["@radroots/sdk".to_string()].into_iter().collect()
+ );
} else {
for package in mapping.packages.values() {
assert!(!package.trim().is_empty());
@@ -5290,6 +5361,9 @@ model_crates = ["radroots_a"]
algorithm_crates = ["radroots_b"]
wasm_crates = ["radroots_a_wasm"]
+[export.ts]
+packages = ["@radroots/sdk"]
+
[policy]
exclude_internal_workspace_crates = false
require_reproducible_exports = true
@@ -5421,7 +5495,7 @@ id = "ts"
repository = "sdk-typescript"
[packages]
-"radroots_a" = "@radroots/a"
+"radroots_a" = "@radroots/sdk"
[artifacts]
models_dir = "src/generated"
@@ -5709,8 +5783,8 @@ id = "ts"
repository = "sdk-typescript"
[packages]
-radroots_a = "@radroots/a"
-radroots_b = "@radroots/b"
+radroots_a = "@radroots/sdk"
+radroots_b = "@radroots/sdk"
[artifacts]
models_dir = "src/generated"
@@ -5736,6 +5810,29 @@ manifest_file = "export-manifest.json"
assert!(artifacts_err.contains("artifacts map is required for ts"));
let _ = fs::remove_dir_all(&missing_artifacts);
+ let curated_package_mismatch = create_synthetic_workspace("bundle_ts_package_mismatch");
+ let mut mismatch_bundle =
+ load_contract_bundle(&curated_package_mismatch).expect("load mismatch bundle");
+ mismatch_bundle.exports[0]
+ .packages
+ .insert("radroots_a".to_string(), "@radroots/other".to_string());
+ assert_eq!(
+ ts_curated_package_set(&mismatch_bundle).expect("ts package set"),
+ Some(["@radroots/sdk".to_string()].into_iter().collect())
+ );
+ assert_eq!(
+ mismatch_bundle.exports[0]
+ .packages
+ .values()
+ .cloned()
+ .collect::<BTreeSet<_>>(),
+ ["@radroots/other".to_string()].into_iter().collect()
+ );
+ let package_err =
+ validate_contract_bundle(&mismatch_bundle).expect_err("ts package mismatch");
+ assert!(package_err.contains("must match manifest export.ts.packages"));
+ let _ = fs::remove_dir_all(&curated_package_mismatch);
+
let release_error_root = create_synthetic_workspace("bundle_release_policy_error");
write_file(
&root_release_policy_path(&release_error_root),
diff --git a/spec/README.md b/spec/README.md
@@ -48,6 +48,8 @@ under `spec/exports/`:
The `sdk-exports` files are the authoritative public package model.
The `exports` files remain the lower-level substrate and artifact mapping layer.
+For TypeScript, that lower-level provenance still resolves to the single
+curated `@radroots/sdk` package rather than a crate-mirrored npm package set.
## Internal Replica Contract
diff --git a/spec/exports/ts.toml b/spec/exports/ts.toml
@@ -3,11 +3,11 @@ id = "ts"
repository = "sdk-typescript"
[packages]
-"radroots_core" = "@radroots/core"
-"radroots_events" = "@radroots/events"
-"radroots_trade" = "@radroots/trade"
-"radroots_identity" = "@radroots/identity"
-"radroots_events_codec_wasm" = "@radroots/events-codec-wasm"
+"radroots_core" = "@radroots/sdk"
+"radroots_events" = "@radroots/sdk"
+"radroots_trade" = "@radroots/sdk"
+"radroots_identity" = "@radroots/sdk"
+"radroots_events_codec_wasm" = "@radroots/sdk"
[artifacts]
models_dir = "src/generated"