lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 432b2ad078bd56ac73733b8610aeaa9b332855ef
parent 0910a51c2a520eb2651ce39304a54ecd9726bfc9
Author: triesap <tyson@radroots.org>
Date:   Sat, 11 Apr 2026 15:18:13 +0000

contract: add operation-first sdk layer

Diffstat:
Aconformance/vectors/farm/build_draft.v1.json | 23+++++++++++++++++++++++
Aconformance/vectors/listing/build_draft.v1.json | 33+++++++++++++++++++++++++++++++++
Aconformance/vectors/listing/build_tags.v1.json | 29+++++++++++++++++++++++++++++
Aconformance/vectors/listing/parse_event.v1.json | 20++++++++++++++++++++
Aconformance/vectors/profile/build_draft.v1.json | 23+++++++++++++++++++++++
Aconformance/vectors/trade/build_envelope_draft.v1.json | 23+++++++++++++++++++++++
Aconformance/vectors/trade/parse_envelope.v1.json | 20++++++++++++++++++++
Aconformance/vectors/trade/parse_listing_address.v1.json | 16++++++++++++++++
Aconformance/vectors/trade/validate_listing_event.v1.json | 20++++++++++++++++++++
Acontract/RCLD.md | 877+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontract/operations.toml | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontract/sdk-exports/ts.toml | 39+++++++++++++++++++++++++++++++++++++++
Mcrates/xtask/src/contract.rs | 1194++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/xtask/src/export_ts.rs | 2++
Mcrates/xtask/src/main.rs | 33+++++++++++++++++++++++++++++++--
15 files changed, 2497 insertions(+), 78 deletions(-)

diff --git a/conformance/vectors/farm/build_draft.v1.json b/conformance/vectors/farm/build_draft.v1.json @@ -0,0 +1,23 @@ +{ + "suite": "farm", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "farm_build_draft_minimal_001", + "kind": "farm.build_draft", + "input": { + "farm": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA", + "name": "example farm" + } + }, + "expected": { + "wire_parts": { + "kind": "farm", + "content_shape": "farm_json", + "tags_shape": "d_tag_required" + } + } + } + ] +} diff --git a/conformance/vectors/listing/build_draft.v1.json b/conformance/vectors/listing/build_draft.v1.json @@ -0,0 +1,33 @@ +{ + "suite": "listing", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "listing_build_draft_minimal_001", + "kind": "listing.build_draft", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "farm": { + "pubkey": "farm_pubkey", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "sku", + "title": "widget", + "category": "tools" + }, + "primary_bin_id": "bin-1", + "bins": [] + } + }, + "expected": { + "wire_parts": { + "kind": "listing", + "content_shape": "listing_markdown_or_empty", + "tags_shape": "listing_tags_full" + } + } + } + ] +} diff --git a/conformance/vectors/listing/build_tags.v1.json b/conformance/vectors/listing/build_tags.v1.json @@ -0,0 +1,29 @@ +{ + "suite": "listing", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "listing_build_tags_minimal_001", + "kind": "listing.build_tags", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "farm": { + "pubkey": "farm_pubkey", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "sku", + "title": "widget", + "category": "tools" + }, + "primary_bin_id": "bin-1", + "bins": [] + } + }, + "expected": { + "tags_shape": "listing_tags" + } + } + ] +} diff --git a/conformance/vectors/listing/parse_event.v1.json b/conformance/vectors/listing/parse_event.v1.json @@ -0,0 +1,20 @@ +{ + "suite": "listing", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "listing_parse_event_minimal_001", + "kind": "listing.parse_event", + "input": { + "event": { + "kind": "listing", + "content_shape": "listing_json_or_markdown", + "tags_shape": "listing_tags_full" + } + }, + "expected": { + "listing_shape": "radroots_listing" + } + } + ] +} diff --git a/conformance/vectors/profile/build_draft.v1.json b/conformance/vectors/profile/build_draft.v1.json @@ -0,0 +1,23 @@ +{ + "suite": "profile", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "profile_build_draft_minimal_001", + "kind": "profile.build_draft", + "input": { + "profile": { + "name": "radroots user" + }, + "profile_type": "farmer" + }, + "expected": { + "wire_parts": { + "kind": "profile", + "content_shape": "metadata_json", + "tags_shape": "profile_type_optional" + } + } + } + ] +} diff --git a/conformance/vectors/trade/build_envelope_draft.v1.json b/conformance/vectors/trade/build_envelope_draft.v1.json @@ -0,0 +1,23 @@ +{ + "suite": "trade", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "trade_build_envelope_draft_minimal_001", + "kind": "trade.build_envelope_draft", + "input": { + "recipient_pubkey": "recipient_pubkey", + "message_type": "order_request", + "listing_addr": "30402:seller_pubkey:AAAAAAAAAAAAAAAAAAAAAg", + "payload_shape": "trade_message_payload" + }, + "expected": { + "wire_parts": { + "kind": "trade", + "content_shape": "trade_envelope_json", + "tags_shape": "trade_envelope_tags" + } + } + } + ] +} diff --git a/conformance/vectors/trade/parse_envelope.v1.json b/conformance/vectors/trade/parse_envelope.v1.json @@ -0,0 +1,20 @@ +{ + "suite": "trade", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "trade_parse_envelope_minimal_001", + "kind": "trade.parse_envelope", + "input": { + "event": { + "kind": "trade", + "content_shape": "trade_envelope_json", + "tags_shape": "trade_envelope_tags" + } + }, + "expected": { + "envelope_shape": "trade_envelope" + } + } + ] +} diff --git a/conformance/vectors/trade/parse_listing_address.v1.json b/conformance/vectors/trade/parse_listing_address.v1.json @@ -0,0 +1,16 @@ +{ + "suite": "trade", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "trade_parse_listing_address_minimal_001", + "kind": "trade.parse_listing_address", + "input": { + "listing_addr": "30402:seller_pubkey:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "address_shape": "trade_listing_address" + } + } + ] +} diff --git a/conformance/vectors/trade/validate_listing_event.v1.json b/conformance/vectors/trade/validate_listing_event.v1.json @@ -0,0 +1,20 @@ +{ + "suite": "trade", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "trade_validate_listing_event_minimal_001", + "kind": "trade.validate_listing_event", + "input": { + "event": { + "kind": "listing", + "content_shape": "listing_json_or_markdown", + "tags_shape": "listing_tags_full" + } + }, + "expected": { + "validation_shape": "trade_listing_validation_result" + } + } + ] +} diff --git a/contract/RCLD.md b/contract/RCLD.md @@ -0,0 +1,877 @@ +# Radroots Cross-Language SDK Contract Design + +Status: approved direction, design artifact + +Scope: public Radroots SDK contract for external language SDKs derived from the Rust workspace in this repository + +Canonical source: Rust remains the canonical implementation and conformance source for public contract behavior + +## Purpose + +This document defines the approved operation-first design for the Radroots cross-language SDK contract. + +It replaces the crate-first mental model currently expressed in `contract/manifest.toml` with a public contract shaped around external integration tasks: + +- produce Radroots-compliant Nostr events +- parse Radroots-compliant Nostr events +- validate Radroots-compliant contract behavior +- preserve deterministic cross-language behavior for supported operations + +This document does not require the Rust workspace to stop using crate boundaries internally. Crates remain implementation and provenance boundaries inside Rust. Operations become the public SDK boundary. + +## Problem + +The current repository expresses SDK surface primarily in terms of Rust crates: + +- `surface.model_crates` +- `surface.algorithm_crates` +- `surface.wasm_crates` +- language export manifests keyed by Rust crate name +- `xtask` validation and export logic that assume crate-to-package mapping + +That framing is not aligned with the needs of third-party integrators. Integrators do not want a mirror of the Rust workspace. They want a small, stable, idiomatic SDK that helps them publish and read Radroots-compliant Nostr events. + +The codebase already contains the correct technical boundary: + +- event model types in `radroots_events`, `radroots_trade`, `radroots_identity`, and supporting model crates +- deterministic builders and parsers in `radroots_events_codec` +- shared unsigned event primitives in `radroots_events_codec::wire` +- optional wasm packaging for deterministic helper logic + +The contract must move upward from crate inventory to public operations. + +## Decisions Ratified + +The following decisions are approved and are treated as default design constraints: + +- external SDKs optimize first for third-party app integrations, not for full Radroots internal app parity +- publishing is the first-class use case +- reading and validation are supported for the same Tier 1 domains, but remain secondary to publishing +- Tier 1 domains are `profile`, `farm`, `listing`, and `trade` +- the public contract unit is an operation, not a crate +- networking and signing remain native to each target language +- TypeScript is the first reference SDK for the new contract +- Python follows after TypeScript proves the operation model +- Rust crate names are not part of the public SDK mental model +- migration should be additive first and support old and new manifest shapes during transition + +## Goals + +- define a stable public SDK contract in terms of operations +- preserve Rust as the canonical behavioral implementation +- support idiomatic language SDKs without requiring full Rust API parity +- keep transport, relay IO, and signing runtime-native +- make deterministic encode, parse, normalize, and validate behavior conformance-testable +- provide a migration path from the current crate-keyed contract and export system + +## Non-Goals + +- exporting every Rust function to every language +- standardizing one shared Nostr client implementation across languages +- exposing Radroots app-internal marketplace, replica, moderation, or backoffice surfaces as public SDK APIs +- introducing a flag-day rewrite of the entire contract and export toolchain + +## External Audience + +The public SDK contract is designed for: + +- third-party apps publishing Radroots-compliant profiles, farms, listings, and trade events +- apps that need to parse or validate those supported event families +- language SDK maintainers implementing contract-compliant APIs in TypeScript, Python, Swift, and Kotlin + +The public SDK contract is not designed for: + +- exposing Radroots internal admin flows +- exposing internal replica storage contracts +- exposing internal moderation and backoffice read models + +## Public Contract Principles + +1. Operations are public. Crates are internal. +2. Inputs, outputs, and errors are explicit. +3. Deterministic behavior is contract material. +4. Cross-language conformance is mandatory for approved operations. +5. Runtime choices such as relay transport and signer integration remain language-native. +6. Public surface must be narrower than the full Rust workspace. +7. Internal app-specific projections are excluded unless explicitly promoted. + +## Public Surface Taxonomy + +The public SDK contract has four surface classes: + +### 1. Operations + +Task-oriented public entry points for supported domains. + +Examples: + +- `profile.build_draft` +- `farm.build_draft` +- `listing.build_tags` +- `listing.build_draft` +- `listing.parse_event` +- `trade.build_envelope_draft` +- `trade.parse_envelope` +- `trade.parse_listing_address` +- `trade.validate_listing_event` + +### 2. Shared Types + +Public cross-operation types required for operation inputs and outputs. + +Examples: + +- `WireEventParts` +- `UnsignedEventDraft` +- `RadrootsNostrEvent` +- `RadrootsNostrEventRef` +- `RadrootsTradeListingAddress` + +### 3. Shared Errors + +Public error categories and domain-specific parse and validation errors that languages must preserve semantically. + +Examples: + +- event encode errors +- listing parse errors +- trade envelope parse errors +- listing validation errors + +### 4. Implementation Provenance + +Rust crate and wasm provenance used by maintainers and tooling, but not treated as the public contract unit. + +Examples: + +- operation implemented in `radroots_events_codec` +- type defined in `radroots_events` +- deterministic helper exposed via `radroots_events_codec_wasm` + +## Tier 1 Domains And Operations + +The initial approved public domains are `profile`, `farm`, `listing`, and `trade`. + +The following operations form the recommended Tier 1 surface. + +### Profile + +#### `profile.build_draft` + +Purpose: produce an unsigned Nostr event draft for a Radroots profile event + +Rust implementation sources: + +- `crates/events_codec/src/profile/encode.rs` + +Input: + +- `RadrootsProfile` +- optional `RadrootsProfileType` + +Output: + +- `WireEventParts` +- optional `UnsignedEventDraft` helper via shared draft adapter + +Determinism: + +- required + +Runtime ownership: + +- signing: native +- transport: native + +### Farm + +#### `farm.build_draft` + +Purpose: produce an unsigned Nostr event draft for a farm event + +Rust implementation sources: + +- `crates/events_codec/src/farm/encode.rs` + +Input: + +- `RadrootsFarm` + +Output: + +- `WireEventParts` + +Determinism: + +- required + +Runtime ownership: + +- signing: native +- transport: native + +### Listing + +#### `listing.build_tags` + +Purpose: produce canonical listing tags without creating a full unsigned event + +Rust implementation sources: + +- `crates/events_codec/src/listing/encode.rs` +- `crates/events_codec/src/listing/tags.rs` + +Input: + +- `RadrootsListing` + +Output: + +- `Vec<Vec<String>>` + +Determinism: + +- required + +#### `listing.build_draft` + +Purpose: produce an unsigned listing event contract from a listing model + +Rust implementation sources: + +- `crates/events_codec/src/listing/encode.rs` +- `crates/events_codec/src/wire.rs` + +Input: + +- `RadrootsListing` +- optional listing kind override when explicitly allowed + +Output: + +- `WireEventParts` +- optionally adapted to `UnsignedEventDraft` + +Determinism: + +- required + +#### `listing.parse_event` + +Purpose: parse a listing event into the canonical listing model + +Rust implementation sources: + +- `crates/trade/src/listing/codec.rs` +- `crates/events_codec/src/listing/decode.rs` + +Input: + +- `RadrootsNostrEvent` + +Output: + +- `RadrootsListing` + +Determinism: + +- required + +### Trade + +#### `trade.build_envelope_draft` + +Purpose: produce an unsigned trade envelope event from typed trade payload input + +Rust implementation sources: + +- `crates/events_codec/src/trade/encode.rs` + +Input: + +- recipient pubkey +- trade message type +- listing address +- optional order id +- optional listing event pointer +- optional root event id +- optional previous event id +- typed trade payload + +Output: + +- `WireEventParts` + +Determinism: + +- required + +#### `trade.parse_envelope` + +Purpose: parse a trade event into a typed trade envelope + +Rust implementation sources: + +- `crates/events_codec/src/trade/decode.rs` + +Input: + +- `RadrootsNostrEvent` + +Output: + +- typed `RadrootsTradeEnvelope<T>` + +Determinism: + +- required + +#### `trade.parse_listing_address` + +Purpose: parse and validate the canonical listing address used by trade flows + +Rust implementation sources: + +- `crates/events_codec/src/trade/decode.rs` + +Input: + +- listing address string + +Output: + +- `RadrootsTradeListingAddress` + +Determinism: + +- required + +#### `trade.validate_listing_event` + +Purpose: validate that an event meets Radroots listing contract expectations for trade workflows + +Rust implementation sources: + +- `crates/trade/src/listing/validation.rs` + +Input: + +- `RadrootsNostrEvent` +- optional fetched dependencies if the validation path requires them + +Output: + +- validation result structure or domain validation error + +Determinism: + +- required for local validation logic +- explicitly scoped where external dependency fetch is involved + +## Shared Types + +The public contract should explicitly enumerate a minimal shared type set. + +Recommended Tier 1 shared types: + +- `WireEventParts` +- `UnsignedEventDraft` +- `RadrootsNostrEvent` +- `RadrootsNostrEventRef` +- `RadrootsNostrEventPtr` +- `RadrootsTradeListingAddress` +- public model types required by Tier 1 operations: +- `RadrootsProfile` +- `RadrootsFarm` +- `RadrootsListing` +- trade payload and envelope types required by approved trade operations + +`UnsignedEventDraft` should be a public contract alias or wrapper over the current `EventDraft` concept in `crates/events_codec/src/wire.rs`. The public naming should emphasize unsigned event construction rather than internal adapter mechanics. + +## Shared Errors + +The contract should distinguish between: + +- semantic error categories that are part of the public API +- internal implementation error types that can be mapped privately + +Recommended public error classes: + +- `encode_error` +- `parse_error` +- `validation_error` +- `address_error` + +Recommended public semantic guarantees: + +- required-field failures remain distinguishable +- invalid-kind failures remain distinguishable +- invalid-json failures remain distinguishable where applicable +- domain-specific parse mismatches remain distinguishable for listing and trade operations + +Language SDKs may translate exact type names, but they must preserve error meaning and conformance behavior. + +## Explicit Exclusions From The Public SDK + +The following surfaces remain internal unless separately promoted: + +- `radroots_replica_*` surfaces +- backoffice overlays +- marketplace read models and projections +- internal moderation models +- full `radroots_nostr` client runtime +- internal runtime management contracts + +This exclusion is important because the Rust workspace contains valuable internal app surfaces that are not appropriate to freeze as external SDK contract. + +## Runtime Ownership Model + +The cross-language contract owns: + +- deterministic model encode behavior +- parse behavior +- validation behavior +- canonical tags and content construction +- canonical address and pointer parsing + +The language runtime owns: + +- relay transport +- signer integration +- key management +- subscription lifecycle +- connection policies +- local storage and caching choices + +This means SDKs should primarily produce and consume unsigned or already-signed event shapes rather than wrapping one shared transport stack. + +## Package Strategy + +### Public Package Strategy + +The public package strategy should be operation-first and ergonomic. + +Recommended TypeScript package strategy: + +- one main package, for example `@radroots/sdk` +- one optional deterministic helper package or embedded asset for wasm-backed helpers + +Recommended Python package strategy: + +- one main package, for example `radroots_sdk` +- optional implementation-private native or wasm helper assets + +Recommended Swift and Kotlin strategy: + +- one main package or module namespace per language +- helper implementation details remain private unless explicitly useful + +### What Not To Ship + +Do not use crate-mirror packages as the primary public shape: + +- `@radroots/core` +- `@radroots/types` +- `@radroots/events` +- `@radroots/trade` +- `@radroots/identity` + +Those may remain transitional or internal build artifacts, but they should not be the product definition for external integrators. + +## Contract Schema v2 + +The new contract should be additive first. The repository should support both: + +- the existing crate-keyed contract metadata +- a new operation-keyed contract schema + +The operation-keyed schema should become the public source of truth. Crate metadata should become provenance or migration-only data. + +### Recommended Top-Level Manifest Shape + +```toml +[contract] +name = "radroots-sdk-contract" +version = "0.2.0-alpha.1" +source = "rust" +stability = "draft" + +[public] +domains = ["profile", "farm", "listing", "trade"] + +[shared_types] +public = [ + "WireEventParts", + "UnsignedEventDraft", + "RadrootsNostrEvent", + "RadrootsNostrEventRef", + "RadrootsNostrEventPtr", + "RadrootsTradeListingAddress", + "RadrootsProfile", + "RadrootsFarm", + "RadrootsListing", +] + +[errors] +classes = ["encode_error", "parse_error", "validation_error", "address_error"] + +[operations.profile_build_draft] +domain = "profile" +id = "profile.build_draft" +stability = "beta" +inputs = ["RadrootsProfile", "RadrootsProfileType?"] +outputs = ["WireEventParts"] +error_class = "encode_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.profile_build_draft.implementation] +rust_modules = ["crates/events_codec/src/profile/encode.rs"] +rust_types = ["radroots_events::profile::RadrootsProfile"] + +[operations.profile_build_draft.conformance] +vector = "conformance/vectors/profile/build_draft.v1.json" + +[operations.listing_build_draft] +domain = "listing" +id = "listing.build_draft" +stability = "beta" +inputs = ["RadrootsListing"] +outputs = ["WireEventParts"] +error_class = "encode_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.listing_build_draft.implementation] +rust_modules = [ + "crates/events_codec/src/listing/encode.rs", + "crates/events_codec/src/listing/tags.rs", + "crates/events_codec/src/wire.rs", +] + +[operations.listing_build_draft.conformance] +vector = "conformance/vectors/listing/build_draft.v1.json" +``` + +### Provenance Section + +During migration, crate provenance should remain available: + +```toml +[implementation_provenance] +model_crates = [ + "radroots_core", + "radroots_types", + "radroots_events", + "radroots_trade", + "radroots_identity", +] +algorithm_crates = ["radroots_events_codec"] +wasm_crates = ["radroots_events_codec_wasm"] +``` + +This keeps current workspace knowledge available without making it the public contract unit. + +## Language Export Manifest v2 + +Language export manifests should stop mapping crate names to packages as the primary concept. + +Instead they should answer: + +- which operations are supported in the language +- where those operations are exposed +- how deterministic logic is implemented +- which shared types and error classes are public + +### Recommended TypeScript Export Shape + +```toml +[language] +id = "ts" +repository = "sdk-typescript" + +[sdk] +package = "@radroots/sdk" +module_format = "esm" +deterministic_codec = "wasm" +signing = "native" +networking = "native" + +[operations] +"profile.build_draft" = "profile.buildDraft" +"farm.build_draft" = "farm.buildDraft" +"listing.build_tags" = "listing.buildTags" +"listing.build_draft" = "listing.buildDraft" +"listing.parse_event" = "listing.parseEvent" +"trade.build_envelope_draft" = "trade.buildEnvelopeDraft" +"trade.parse_envelope" = "trade.parseEnvelope" +"trade.parse_listing_address" = "trade.parseListingAddress" +"trade.validate_listing_event" = "trade.validateListingEvent" + +[shared_types] +"WireEventParts" = "WireEventParts" +"UnsignedEventDraft" = "UnsignedEventDraft" +"RadrootsNostrEvent" = "RadrootsNostrEvent" +"RadrootsTradeListingAddress" = "TradeListingAddress" + +[artifacts] +models_dir = "src/generated" +runtime_dir = "src/runtime" +wasm_dist_dir = "dist" +manifest_file = "export-manifest.json" +``` + +Equivalent manifests for Python, Swift, and Kotlin should use their own naming conventions. + +## Export Manifest Output + +`xtask` should write an export manifest that includes operation coverage metadata, not only file hashes. + +Recommended structure: + +```json +{ + "language": "ts", + "sdk_package": "@radroots/sdk", + "operations": [ + { + "id": "listing.build_draft", + "symbol": "listing.buildDraft", + "deterministic_codec": "wasm" + } + ], + "files": [ + { + "path": "src/generated/listing.ts", + "sha256": "..." + } + ] +} +``` + +## Conformance Model + +Conformance becomes the real multi-language product gate for the public contract. + +### Rules + +- every public operation must have at least one conformance vector suite +- deterministic operations require positive and negative vectors +- parse operations require round-trip or semantic equivalence vectors where applicable +- error behavior that is part of the contract must be vectorized +- language SDKs must pass conformance without local overrides + +### Recommended Vector Layout + +```text +conformance/ + vectors/ + profile/ + build_draft.v1.json + farm/ + build_draft.v1.json + listing/ + build_tags.v1.json + build_draft.v1.json + parse_event.v1.json + trade/ + build_envelope_draft.v1.json + parse_envelope.v1.json + parse_listing_address.v1.json + validate_listing_event.v1.json +``` + +### Minimum Vector Coverage + +For each operation: + +- one minimal valid case +- one rich valid case +- one canonical normalization case if normalization exists +- one required-field failure case +- one invalid-format or invalid-kind failure case where applicable + +## Versioning Policy v2 + +The contract version policy must shift from exported crate surface to operation semantics. + +### Major Version Triggers + +- remove a public operation +- change required operation input shape +- change output shape incompatibly +- change deterministic operation behavior incompatibly +- collapse or remove a public error distinction + +### Minor Version Triggers + +- add a public operation +- add optional input or output fields +- add a new public shared type +- add new conformance vectors that extend supported behavior without breaking old behavior + +### Patch Version Triggers + +- documentation fixes +- packaging fixes without behavior changes +- non-behavioral codegen fixes +- bug fixes that do not change the contract shape and do not invalidate existing conforming clients + +## Rust Implementation Strategy + +The Rust workspace should add a curated facade for approved public operations. + +Recommended shape: + +- add a new crate or dedicated public module namespace, for example `radroots_sdk_contract` +- re-export only approved operations and approved shared types +- keep direct crate internals available for Rust maintainers but do not treat them as the cross-language contract by default + +This facade should: + +- define public operation names +- define any public wrapper naming such as `UnsignedEventDraft` +- centralize contract documentation and source references +- make it easier for generators and future language bindings to target one approved surface + +## `xtask` Migration Strategy + +### Current State + +`xtask` currently: + +- parses a crate-keyed surface +- validates crate-keyed export coverage +- exports TypeScript by iterating crate-to-package mappings +- has tests that assert TypeScript export coverage matches model plus wasm crates + +### Required Changes + +1. add new manifest parsing structs for operation-based contract metadata +2. support dual parsing during migration +3. introduce validation for: +- non-empty public domain list +- unique operation ids +- known shared type references +- conformance vector presence for each public operation +- language export manifests mapping approved operations +4. replace crate-coverage assertions with operation-coverage assertions +5. update export manifest generation to report operation coverage +6. keep current crate provenance checks only as implementation validation + +### Recommended `xtask` Command Evolution + +Keep existing commands temporarily: + +- `sdk export-ts` +- `sdk validate` + +Add new migration-aware behavior behind the same commands: + +- `sdk validate` validates both old and new contract surfaces +- `sdk export-ts` assembles an operation-first TypeScript SDK package from approved operations + +Optional additive commands: + +- `sdk validate-operations` +- `sdk export-ts-sdk` +- `sdk conformance check --language <id>` + +## Language SDK Strategy + +### TypeScript + +TypeScript is the reference external SDK. + +Implementation recommendation: + +- keep Rust-driven generated models where useful +- use wasm for deterministic codec helpers where beneficial +- handwrite the final ergonomic operation surface +- expose one main SDK package + +### Python + +Python follows after TypeScript proves the operation model. + +Implementation recommendation: + +- do not mirror Rust crates directly +- either implement pure-Python adapters on top of contract artifacts or bind a very small deterministic Rust core +- keep packaging centered on one main SDK package + +### Swift And Kotlin + +Swift and Kotlin should wait until the operation contract is stable and conformance coverage is broader. + +Implementation recommendation: + +- keep the same operation contract +- keep runtime integration native +- only introduce shared native bindings for a narrow deterministic core if the maintenance tradeoff is justified + +## Migration Plan + +### Phase 0: Ratify Design + +- adopt this design as the operation-first target +- treat current crate-keyed metadata as migration-only + +### Phase 1: Add New Contract Metadata + +- add operation-based metadata to the contract directory +- keep crate provenance metadata for existing tooling +- do not remove current manifest shape yet + +### Phase 2: Add Curated Rust Facade + +- introduce a public Rust contract facade +- map approved operations to existing implementation functions +- exclude projections, overlays, replica, and backoffice surfaces + +### Phase 3: Expand Conformance + +- add operation-based vectors for Tier 1 operations +- make vector coverage a release-blocking validation rule + +### Phase 4: Migrate TypeScript Export + +- shift `xtask export-ts` to operation-first packaging +- ship one main external TypeScript SDK package +- keep transitional generated artifacts internal if necessary + +### Phase 5: Introduce Python + +- implement Python export or packaging against the same contract and vector set + +### Phase 6: Remove Crate-First Public Assumptions + +- remove tests and validation that require package coverage by Rust crate name +- keep crate provenance only as internal documentation and maintenance metadata + +## Acceptance Criteria + +This design is implemented successfully when: + +- the public contract manifest declares operations, shared types, and error classes +- `xtask validate` enforces operation coverage and conformance presence +- the curated Rust facade exposes only approved public operations +- the TypeScript SDK ships an operation-first public API +- conformance vectors exist for every Tier 1 operation +- public docs describe the SDK in terms of operations, not Rust crates + +## Immediate Next Workstreams + +1. introduce contract schema v2 files and parser structs +2. define the exact Tier 1 operation ids in machine-readable metadata +3. add a Rust public facade crate or module for those operations +4. author conformance vectors for every Tier 1 operation +5. rewrite `xtask` validator assumptions +6. redesign the TypeScript export manifest and package assembly +7. draft the first external TypeScript SDK surface around the approved operations + +## Repository Notes + +This document intentionally does not modify the current crate-keyed contract files in place. The repository currently contains user edits in several existing files, including `contract/README`. The recommended implementation path is to add the new operation-first contract artifacts alongside the current files first, then migrate validation and export tooling incrementally. diff --git a/contract/operations.toml b/contract/operations.toml @@ -0,0 +1,223 @@ +[contract] +name = "radroots-sdk-contract" +version = "0.1.0-alpha.1" +source = "rust" + +[public] +domains = ["profile", "farm", "listing", "trade"] + +[shared_types] +public = [ + "WireEventParts", + "UnsignedEventDraft", + "RadrootsNostrEvent", + "RadrootsNostrEventRef", + "RadrootsNostrEventPtr", + "RadrootsTradeListingAddress", + "RadrootsProfile", + "RadrootsFarm", + "RadrootsListing", + "RadrootsTradeEnvelope", +] + +[errors] +classes = ["encode_error", "parse_error", "validation_error", "address_error"] + +[implementation_provenance] +model_crates = [ + "radroots_core", + "radroots_types", + "radroots_events", + "radroots_trade", + "radroots_identity", +] +algorithm_crates = ["radroots_events_codec"] +wasm_crates = ["radroots_events_codec_wasm"] + +[operations.profile_build_draft] +domain = "profile" +id = "profile.build_draft" +stability = "beta" +inputs = ["RadrootsProfile", "RadrootsProfileType?"] +outputs = ["WireEventParts"] +error_class = "encode_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.profile_build_draft.implementation] +rust_modules = ["crates/events_codec/src/profile/encode.rs"] +rust_types = ["radroots_events::profile::RadrootsProfile"] + +[operations.profile_build_draft.conformance] +vector = "conformance/vectors/profile/build_draft.v1.json" + +[operations.farm_build_draft] +domain = "farm" +id = "farm.build_draft" +stability = "beta" +inputs = ["RadrootsFarm"] +outputs = ["WireEventParts"] +error_class = "encode_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.farm_build_draft.implementation] +rust_modules = ["crates/events_codec/src/farm/encode.rs"] +rust_types = ["radroots_events::farm::RadrootsFarm"] + +[operations.farm_build_draft.conformance] +vector = "conformance/vectors/farm/build_draft.v1.json" + +[operations.listing_build_tags] +domain = "listing" +id = "listing.build_tags" +stability = "beta" +inputs = ["RadrootsListing"] +outputs = ["NostrTags"] +error_class = "encode_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.listing_build_tags.implementation] +rust_modules = [ + "crates/events_codec/src/listing/encode.rs", + "crates/events_codec/src/listing/tags.rs", +] +rust_types = ["radroots_events::listing::RadrootsListing"] + +[operations.listing_build_tags.conformance] +vector = "conformance/vectors/listing/build_tags.v1.json" + +[operations.listing_build_draft] +domain = "listing" +id = "listing.build_draft" +stability = "beta" +inputs = ["RadrootsListing"] +outputs = ["WireEventParts"] +error_class = "encode_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.listing_build_draft.implementation] +rust_modules = [ + "crates/events_codec/src/listing/encode.rs", + "crates/events_codec/src/wire.rs", +] +rust_types = ["radroots_events::listing::RadrootsListing"] + +[operations.listing_build_draft.conformance] +vector = "conformance/vectors/listing/build_draft.v1.json" + +[operations.listing_parse_event] +domain = "listing" +id = "listing.parse_event" +stability = "beta" +inputs = ["RadrootsNostrEvent"] +outputs = ["RadrootsListing"] +error_class = "parse_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.listing_parse_event.implementation] +rust_modules = ["crates/trade/src/listing/codec.rs"] +rust_types = [ + "radroots_events::RadrootsNostrEvent", + "radroots_events::listing::RadrootsListing", +] + +[operations.listing_parse_event.conformance] +vector = "conformance/vectors/listing/parse_event.v1.json" + +[operations.trade_build_envelope_draft] +domain = "trade" +id = "trade.build_envelope_draft" +stability = "beta" +inputs = [ + "recipient_pubkey", + "message_type", + "listing_addr", + "order_id?", + "listing_event?", + "root_event_id?", + "prev_event_id?", + "RadrootsTradeMessagePayload", +] +outputs = ["WireEventParts"] +error_class = "encode_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.trade_build_envelope_draft.implementation] +rust_modules = ["crates/events_codec/src/trade/encode.rs"] +rust_types = ["radroots_events::trade::RadrootsTradeEnvelope"] + +[operations.trade_build_envelope_draft.conformance] +vector = "conformance/vectors/trade/build_envelope_draft.v1.json" + +[operations.trade_parse_envelope] +domain = "trade" +id = "trade.parse_envelope" +stability = "beta" +inputs = ["RadrootsNostrEvent"] +outputs = ["RadrootsTradeEnvelope"] +error_class = "parse_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.trade_parse_envelope.implementation] +rust_modules = ["crates/events_codec/src/trade/decode.rs"] +rust_types = [ + "radroots_events::RadrootsNostrEvent", + "radroots_events::trade::RadrootsTradeEnvelope", +] + +[operations.trade_parse_envelope.conformance] +vector = "conformance/vectors/trade/parse_envelope.v1.json" + +[operations.trade_parse_listing_address] +domain = "trade" +id = "trade.parse_listing_address" +stability = "beta" +inputs = ["listing_addr"] +outputs = ["RadrootsTradeListingAddress"] +error_class = "address_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.trade_parse_listing_address.implementation] +rust_modules = ["crates/events_codec/src/trade/decode.rs"] +rust_types = [ + "radroots_events_codec::trade::decode::RadrootsTradeListingAddress", +] + +[operations.trade_parse_listing_address.conformance] +vector = "conformance/vectors/trade/parse_listing_address.v1.json" + +[operations.trade_validate_listing_event] +domain = "trade" +id = "trade.validate_listing_event" +stability = "beta" +inputs = ["RadrootsNostrEvent"] +outputs = ["TradeListingValidateResult"] +error_class = "validation_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.trade_validate_listing_event.implementation] +rust_modules = ["crates/trade/src/listing/validation.rs"] +rust_types = [ + "radroots_events::RadrootsNostrEvent", + "radroots_trade::listing::validation::RadrootsTradeListing", +] + +[operations.trade_validate_listing_event.conformance] +vector = "conformance/vectors/trade/validate_listing_event.v1.json" diff --git a/contract/sdk-exports/ts.toml b/contract/sdk-exports/ts.toml @@ -0,0 +1,39 @@ +[language] +id = "ts" +repository = "sdk-typescript" + +[sdk] +package = "@radroots/sdk" +module_format = "esm" +deterministic_codec = "wasm" +signing = "native" +networking = "native" + +[operations] +"profile.build_draft" = "profile.buildDraft" +"farm.build_draft" = "farm.buildDraft" +"listing.build_tags" = "listing.buildTags" +"listing.build_draft" = "listing.buildDraft" +"listing.parse_event" = "listing.parseEvent" +"trade.build_envelope_draft" = "trade.buildEnvelopeDraft" +"trade.parse_envelope" = "trade.parseEnvelope" +"trade.parse_listing_address" = "trade.parseListingAddress" +"trade.validate_listing_event" = "trade.validateListingEvent" + +[shared_types] +"WireEventParts" = "WireEventParts" +"UnsignedEventDraft" = "UnsignedEventDraft" +"RadrootsNostrEvent" = "RadrootsNostrEvent" +"RadrootsNostrEventRef" = "RadrootsNostrEventRef" +"RadrootsNostrEventPtr" = "RadrootsNostrEventPtr" +"RadrootsTradeListingAddress" = "TradeListingAddress" +"RadrootsProfile" = "RadrootsProfile" +"RadrootsFarm" = "RadrootsFarm" +"RadrootsListing" = "RadrootsListing" +"RadrootsTradeEnvelope" = "TradeEnvelope" + +[artifacts] +models_dir = "src/generated" +runtime_dir = "src/runtime" +wasm_dist_dir = "dist" +manifest_file = "export-manifest.json" diff --git a/crates/xtask/src/contract.rs b/crates/xtask/src/contract.rs @@ -40,6 +40,65 @@ pub struct Policy { } #[derive(Debug, Deserialize)] +pub struct OperationsContractManifest { + pub contract: ManifestContract, + pub public: PublicContract, + pub shared_types: SharedTypesContract, + pub errors: ErrorClassesContract, + pub operations: BTreeMap<String, PublicOperationContract>, + pub implementation_provenance: Option<ImplementationProvenance>, +} + +#[derive(Debug, Deserialize)] +pub struct PublicContract { + pub domains: Vec<String>, +} + +#[derive(Debug, Deserialize)] +pub struct SharedTypesContract { + pub public: Vec<String>, +} + +#[derive(Debug, Deserialize)] +pub struct ErrorClassesContract { + pub classes: Vec<String>, +} + +#[derive(Debug, Deserialize)] +pub struct ImplementationProvenance { + pub model_crates: Vec<String>, + pub algorithm_crates: Vec<String>, + pub wasm_crates: Vec<String>, +} + +#[derive(Debug, Deserialize)] +pub struct PublicOperationContract { + pub domain: String, + pub id: String, + pub stability: String, + pub inputs: Vec<String>, + pub outputs: Vec<String>, + pub error_class: String, + #[allow(dead_code)] + pub deterministic: bool, + pub signing: String, + pub transport: String, + pub implementation: PublicOperationImplementation, + pub conformance: PublicOperationConformance, +} + +#[derive(Debug, Deserialize)] +pub struct PublicOperationImplementation { + pub rust_modules: Vec<String>, + pub rust_types: Vec<String>, +} + +#[derive(Debug, Deserialize)] +pub struct PublicOperationConformance { + pub vector: String, +} + +#[derive(Debug, Deserialize)] pub struct VersionPolicy { pub contract: VersionContract, pub semver: SemverRules, @@ -87,12 +146,40 @@ pub struct ExportArtifacts { pub manifest_file: Option<String>, } +#[derive(Debug, Deserialize)] +pub struct SdkExportMapping { + pub language: ExportLanguage, + pub sdk: SdkExportSdk, + pub operations: BTreeMap<String, String>, + pub shared_types: BTreeMap<String, String>, + pub artifacts: Option<SdkExportArtifacts>, +} + +#[derive(Debug, Deserialize)] +pub struct SdkExportSdk { + pub package: String, + pub module_format: Option<String>, + pub deterministic_codec: String, + pub signing: String, + pub networking: String, +} + +#[derive(Debug, Deserialize, Default)] +pub struct SdkExportArtifacts { + pub models_dir: Option<String>, + pub runtime_dir: Option<String>, + pub wasm_dist_dir: Option<String>, + pub manifest_file: Option<String>, +} + #[derive(Debug)] pub struct ContractBundle { pub root: PathBuf, pub manifest: ContractManifest, pub version: VersionPolicy, pub exports: Vec<ExportMapping>, + pub operations_manifest: Option<OperationsContractManifest>, + pub sdk_exports: Vec<SdkExportMapping>, } #[derive(Debug, Deserialize)] @@ -171,6 +258,19 @@ struct ReleaseCrateSet { crates: Vec<String>, } +#[derive(Debug, Deserialize)] +struct ConformanceVectorFile { + suite: String, + contract_version: String, + vectors: Vec<ConformanceVectorEntry>, +} + +#[derive(Debug, Deserialize)] +struct ConformanceVectorEntry { + id: String, + kind: String, +} + impl ReleaseContractFile { fn uses_classification(&self) -> bool { !self.classification.public.is_empty() @@ -224,10 +324,25 @@ fn parse_toml<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> { } } +fn parse_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(e) => return Err(format!("read {}: {e}", path.display())), + }; + match serde_json::from_str::<T>(&raw) { + Ok(parsed) => Ok(parsed), + Err(e) => Err(format!("parse {}: {e}", path.display())), + } +} + fn contract_root(workspace_root: &Path) -> PathBuf { workspace_root.join("contract") } +fn base_contract_version(version: &str) -> &str { + version.split_once('-').map_or(version, |(base, _)| base) +} + #[derive(Debug)] struct WorkspacePackageRecord { name: String, @@ -461,6 +576,336 @@ fn collect_unique_set(items: &[String], field: &str) -> Result<BTreeSet<String>, Ok(set) } +fn collect_non_empty_set(items: &[String], field: &str) -> Result<BTreeSet<String>, String> { + let mut set = BTreeSet::new(); + for item in items { + if item.trim().is_empty() { + return Err(format!("{field} contains an empty value")); + } + if !set.insert(item.clone()) { + return Err(format!("{field} has duplicate value {}", item)); + } + } + Ok(set) +} + +fn validate_operations_contract( + bundle: &ContractBundle, + operations_manifest: &OperationsContractManifest, + workspace_root: &Path, +) -> Result<(), String> { + if operations_manifest.contract.name.trim().is_empty() { + return Err("operations contract name is required".to_string()); + } + if operations_manifest.contract.version.trim().is_empty() { + return Err("operations contract version is required".to_string()); + } + if operations_manifest.contract.source.trim().is_empty() { + return Err("operations contract source is required".to_string()); + } + if operations_manifest.contract.name != bundle.manifest.contract.name { + return Err("operations contract name must match manifest contract name".to_string()); + } + if operations_manifest.contract.version != bundle.manifest.contract.version { + return Err("operations contract version must match manifest contract version".to_string()); + } + if operations_manifest.contract.source != bundle.manifest.contract.source { + return Err("operations contract source must match manifest contract source".to_string()); + } + + let domains = collect_non_empty_set(&operations_manifest.public.domains, "public.domains")?; + if domains.is_empty() { + return Err("public.domains must not be empty".to_string()); + } + let shared_types = collect_non_empty_set( + &operations_manifest.shared_types.public, + "shared_types.public", + )?; + if shared_types.is_empty() { + return Err("shared_types.public must not be empty".to_string()); + } + let error_classes = + collect_non_empty_set(&operations_manifest.errors.classes, "errors.classes")?; + if error_classes.is_empty() { + return Err("errors.classes must not be empty".to_string()); + } + if operations_manifest.operations.is_empty() { + return Err("operations map must not be empty".to_string()); + } + + if let Some(provenance) = &operations_manifest.implementation_provenance { + let manifest_models = collect_unique_set( + &bundle.manifest.surface.model_crates, + "surface.model_crates", + )?; + let manifest_algorithms = collect_unique_set( + &bundle.manifest.surface.algorithm_crates, + "surface.algorithm_crates", + )?; + let manifest_wasm = + collect_unique_set(&bundle.manifest.surface.wasm_crates, "surface.wasm_crates")?; + let provenance_models = collect_unique_set( + &provenance.model_crates, + "implementation_provenance.model_crates", + )?; + let provenance_algorithms = collect_unique_set( + &provenance.algorithm_crates, + "implementation_provenance.algorithm_crates", + )?; + let provenance_wasm = collect_unique_set( + &provenance.wasm_crates, + "implementation_provenance.wasm_crates", + )?; + if provenance_models != manifest_models + || provenance_algorithms != manifest_algorithms + || provenance_wasm != manifest_wasm + { + return Err( + "operations implementation_provenance must match manifest surface crates" + .to_string(), + ); + } + } + + let mut operation_ids = BTreeSet::new(); + for (operation_key, operation) in &operations_manifest.operations { + if operation_key.trim().is_empty() { + return Err("operations map contains an empty key".to_string()); + } + if operation.domain.trim().is_empty() { + return Err(format!("operation {} domain is required", operation_key)); + } + if !domains.contains(&operation.domain) { + return Err(format!( + "operation {} references unknown domain {}", + operation_key, operation.domain + )); + } + if operation.id.trim().is_empty() { + return Err(format!("operation {} id is required", operation_key)); + } + if !operation_ids.insert(operation.id.clone()) { + return Err(format!("operations has duplicate id {}", operation.id)); + } + if operation.stability.trim().is_empty() { + return Err(format!("operation {} stability is required", operation.id)); + } + if !operation.deterministic { + return Err(format!( + "operation {} deterministic must be true for the public contract", + operation.id + )); + } + if operation.inputs.is_empty() { + return Err(format!( + "operation {} inputs must not be empty", + operation.id + )); + } + let _ = collect_non_empty_set( + &operation.inputs, + &format!("operation {} inputs", operation.id), + )?; + if operation.outputs.is_empty() { + return Err(format!( + "operation {} outputs must not be empty", + operation.id + )); + } + let _ = collect_non_empty_set( + &operation.outputs, + &format!("operation {} outputs", operation.id), + )?; + if !error_classes.contains(&operation.error_class) { + return Err(format!( + "operation {} references unknown error class {}", + operation.id, operation.error_class + )); + } + if operation.signing.trim().is_empty() { + return Err(format!("operation {} signing is required", operation.id)); + } + if operation.transport.trim().is_empty() { + return Err(format!("operation {} transport is required", operation.id)); + } + if operation.implementation.rust_modules.is_empty() { + return Err(format!( + "operation {} implementation.rust_modules must not be empty", + operation.id + )); + } + let _ = collect_non_empty_set( + &operation.implementation.rust_types, + &format!("operation {} implementation.rust_types", operation.id), + )?; + for rust_module in &operation.implementation.rust_modules { + if rust_module.trim().is_empty() { + return Err(format!( + "operation {} implementation.rust_modules contains an empty value", + operation.id + )); + } + let path = workspace_root.join(rust_module); + if !path.is_file() { + return Err(format!( + "operation {} references missing rust module {}", + operation.id, rust_module + )); + } + } + if operation.conformance.vector.trim().is_empty() { + return Err(format!( + "operation {} conformance.vector is required", + operation.id + )); + } + let vector_path = workspace_root.join(&operation.conformance.vector); + let vector = parse_json::<ConformanceVectorFile>(&vector_path)?; + if vector.suite.trim().is_empty() { + return Err(format!( + "operation {} conformance vector suite must not be empty", + operation.id + )); + } + if vector.vectors.is_empty() { + return Err(format!( + "operation {} conformance vector must contain at least one vector", + operation.id + )); + } + if vector.contract_version != base_contract_version(&operations_manifest.contract.version) { + return Err(format!( + "operation {} conformance vector version {} must match contract version {}", + operation.id, + vector.contract_version, + base_contract_version(&operations_manifest.contract.version) + )); + } + for entry in vector.vectors { + if entry.id.trim().is_empty() || entry.kind.trim().is_empty() { + return Err(format!( + "operation {} conformance vector entries must define non-empty id and kind", + operation.id + )); + } + } + } + + if bundle.sdk_exports.is_empty() { + return Err( + "sdk-exports must define at least one operation-based language mapping".to_string(), + ); + } + + let mut has_ts_mapping = false; + for mapping in &bundle.sdk_exports { + if mapping.language.id.trim().is_empty() { + return Err("sdk export language.id is required".to_string()); + } + if mapping.language.repository.trim().is_empty() { + return Err(format!( + "sdk export language.repository is required for {}", + mapping.language.id + )); + } + if mapping.language.id == "ts" { + has_ts_mapping = true; + } + if mapping.sdk.package.trim().is_empty() { + return Err(format!( + "sdk export package is required for {}", + mapping.language.id + )); + } + if mapping.sdk.deterministic_codec.trim().is_empty() + || mapping.sdk.signing.trim().is_empty() + || mapping.sdk.networking.trim().is_empty() + { + return Err(format!( + "sdk runtime fields must be non-empty for {}", + mapping.language.id + )); + } + if let Some(module_format) = mapping.sdk.module_format.as_deref() { + if module_format.trim().is_empty() { + return Err(format!( + "sdk module_format must be non-empty for {}", + mapping.language.id + )); + } + } + if mapping.operations.is_empty() { + return Err(format!( + "sdk export operations map is required for {}", + mapping.language.id + )); + } + for (operation_id, symbol) in &mapping.operations { + if !operation_ids.contains(operation_id) { + return Err(format!( + "sdk export {} references unknown operation {}", + mapping.language.id, operation_id + )); + } + if symbol.trim().is_empty() { + return Err(format!( + "sdk export {} must map operation {} to a non-empty symbol", + mapping.language.id, operation_id + )); + } + } + if mapping.shared_types.is_empty() { + return Err(format!( + "sdk export shared_types map is required for {}", + mapping.language.id + )); + } + for (shared_type, symbol) in &mapping.shared_types { + if !shared_types.contains(shared_type) { + return Err(format!( + "sdk export {} references unknown shared type {}", + mapping.language.id, shared_type + )); + } + if symbol.trim().is_empty() { + return Err(format!( + "sdk export {} must map shared type {} to a non-empty symbol", + mapping.language.id, shared_type + )); + } + } + if mapping.language.id == "ts" { + if operation_ids != mapping.operations.keys().cloned().collect::<BTreeSet<_>>() { + return Err( + "sdk export ts must cover every public operation in operations.toml" + .to_string(), + ); + } + let artifacts = mapping + .artifacts + .as_ref() + .ok_or_else(|| "sdk export artifacts map is required for ts".to_string())?; + for (field, value) in [ + ("models_dir", artifacts.models_dir.as_ref()), + ("runtime_dir", artifacts.runtime_dir.as_ref()), + ("wasm_dist_dir", artifacts.wasm_dist_dir.as_ref()), + ("manifest_file", artifacts.manifest_file.as_ref()), + ] { + if value.is_none_or(|raw| raw.trim().is_empty()) { + return Err(format!( + "sdk export artifacts.{field} must be non-empty for ts" + )); + } + } + } + } + if !has_ts_mapping { + return Err("sdk-exports must include a ts mapping".to_string()); + } + + Ok(()) +} + fn package_field_configured(table: &toml::value::Table, field: &str) -> bool { let Some(value) = table.get(field) else { return false; @@ -701,71 +1146,388 @@ fn validate_core_unit_dimension_variant_order(workspace_root: &Path) -> Result<( variants.join(", ") )); } - Ok(()) -} - -fn validate_coverage_policy_parity( - workspace_root: &Path, - contract_root: &Path, -) -> Result<(), String> { - let workspace_packages = workspace_package_names(workspace_root)? - .into_iter() - .collect::<BTreeSet<_>>(); - let policy = load_coverage_policy(contract_root)?; - let thresholds = policy.thresholds(); - if thresholds.fail_under_exec_lines != 100.0 - || thresholds.fail_under_functions != 100.0 - || thresholds.fail_under_regions != 100.0 - || thresholds.fail_under_branches != 100.0 - || !thresholds.require_branches + Ok(()) +} + +fn validate_coverage_policy_parity( + workspace_root: &Path, + contract_root: &Path, +) -> Result<(), String> { + let workspace_packages = workspace_package_names(workspace_root)? + .into_iter() + .collect::<BTreeSet<_>>(); + let policy = load_coverage_policy(contract_root)?; + let thresholds = policy.thresholds(); + if thresholds.fail_under_exec_lines != 100.0 + || thresholds.fail_under_functions != 100.0 + || thresholds.fail_under_regions != 100.0 + || thresholds.fail_under_branches != 100.0 + || !thresholds.require_branches + { + return Err( + "coverage policy must enforce 100/100/100/100 with required branches".to_string(), + ); + } + + let required_packages = policy + .required_crate_entries() + .iter() + .cloned() + .collect::<BTreeSet<_>>(); + if workspace_packages != required_packages { + let missing = workspace_packages + .difference(&required_packages) + .cloned() + .collect::<BTreeSet<_>>(); + let extra = required_packages + .difference(&workspace_packages) + .cloned() + .collect::<BTreeSet<_>>(); + return Err(format!( + "coverage policy missing workspace crates: {}; coverage policy includes unknown crates: {}", + join_set(&missing), + join_set(&extra) + )); + } + + Ok(()) +} + +fn publish_config_is_public(publish: Option<&PackagePublish>) -> bool { + matches!( + publish, + Some(PackagePublish::Registries(registries)) + if registries.len() == 1 && registries[0] == "crates-io" + ) +} + +fn publish_config_is_non_public(publish: Option<&PackagePublish>) -> bool { + matches!(publish, Some(PackagePublish::Bool(false))) +} + +fn validate_release_publish_policy( + workspace_root: &Path, + contract_root: &Path, + contract_version: &str, +) -> Result<(), String> { + let release = load_release_contract(workspace_root, contract_root)?; + if release.release.version.trim().is_empty() { + return Err("release.version must not be empty".to_string()); + } + if release.release.version != contract_version { + return Err(format!( + "release.version {} must match contract version {}", + release.release.version, contract_version + )); + } + + let workspace_packages = workspace_package_names(workspace_root)? + .into_iter() + .collect::<BTreeSet<_>>(); + let uses_classification = release.uses_classification(); + let public_field = if uses_classification { + "classification.public" + } else { + "publish.crates" + }; + let internal_field = if uses_classification { + "classification.internal" + } else { + "internal.crates" + }; + + let public_set = collect_unique_set(&release.public_crates(), public_field)?; + let internal_set = collect_unique_set(&release.internal_crates(), internal_field)?; + let deferred_set = collect_unique_set(&release.deferred_crates(), "classification.deferred")?; + let retired_set = collect_unique_set(&release.retired_crates(), "classification.retired")?; + let yank_only_set = + collect_unique_set(&release.yank_only_crates(), "classification.yank_only")?; + let publish_order = &release.publish_order.crates; + let publish_order_set = collect_unique_set(publish_order, "publish_order.crates")?; + + let class_sets = [ + ("public", &public_set), + ("internal", &internal_set), + ("deferred", &deferred_set), + ("retired", &retired_set), + ("yank-only", &yank_only_set), + ]; + for idx in 0..class_sets.len() { + for other_idx in (idx + 1)..class_sets.len() { + let overlap = class_sets[idx] + .1 + .intersection(class_sets[other_idx].1) + .cloned() + .collect::<BTreeSet<_>>(); + if !overlap.is_empty() { + return Err(format!( + "release classification overlap is not allowed between {} and {}: {}", + class_sets[idx].0, + class_sets[other_idx].0, + join_set(&overlap) + )); + } + } + } + + let mut combined = public_set.clone(); + combined.extend(internal_set.iter().cloned()); + combined.extend(deferred_set.iter().cloned()); + combined.extend(retired_set.iter().cloned()); + combined.extend(yank_only_set.iter().cloned()); + if combined != workspace_packages { + let missing = workspace_packages + .difference(&combined) + .cloned() + .collect::<BTreeSet<_>>(); + let extra = combined + .difference(&workspace_packages) + .cloned() + .collect::<BTreeSet<_>>(); + return Err(format!( + "release classification sets are missing workspace crates: {}; release classification sets include unknown crates: {}", + join_set(&missing), + join_set(&extra) + )); + } + + if publish_order_set != public_set { + let missing = public_set + .difference(&publish_order_set) + .cloned() + .collect::<BTreeSet<_>>(); + let extra = publish_order_set + .difference(&public_set) + .cloned() + .collect::<BTreeSet<_>>(); + return Err(format!( + "publish_order.crates is missing publish crates: {}; publish_order.crates has non-publish crates: {}", + join_set(&missing), + join_set(&extra) + )); + } + + let order_index = publish_order + .iter() + .enumerate() + .map(|(idx, name)| (name.clone(), idx)) + .collect::<BTreeMap<_, _>>(); + let dependencies = read_workspace_package_dependencies(workspace_root) + .expect("workspace package manifests were already parsed"); + for crate_name in &public_set { + let crate_deps = &dependencies[crate_name]; + let crate_order = order_index[crate_name]; + for dep in crate_deps { + if !public_set.contains(dep) { + continue; + } + let dep_order = order_index[dep]; + if dep_order >= crate_order { + return Err(format!( + "publish order must place dependency {} before {}", + dep, crate_name + )); + } + } + } + + let publish_configs = workspace_package_publish_configs(workspace_root) + .expect("workspace publish configs are stable"); + for crate_name in &public_set { + let publish = publish_configs[crate_name].as_ref(); + if !publish_config_is_public(publish) { + return Err(format!( + "public crate {} must set publish = [\"crates-io\"]", + crate_name + )); + } + } + for crate_name in internal_set + .iter() + .chain(deferred_set.iter()) + .chain(retired_set.iter()) + .chain(yank_only_set.iter()) + { + let publish = publish_configs[crate_name].as_ref(); + if !publish_config_is_non_public(publish) { + return Err(format!( + "non-public crate {} must set publish = false", + crate_name + )); + } + } + + Ok(()) +} + +pub fn validate_release_preflight(workspace_root: &Path) -> Result<(), String> { + validate_release_preflight_with_override(workspace_root, None) +} + +pub fn validate_release_preflight_with_override( + workspace_root: &Path, + release_policy_override: Option<PathBuf>, +) -> Result<(), String> { + let bundle = load_contract_bundle(workspace_root)?; + validate_contract_bundle_with_release_policy_override( + &bundle, + release_policy_override.clone(), + )?; + let release = + load_release_contract_with_override(workspace_root, &bundle.root, release_policy_override)?; + let policy = + load_coverage_policy(&bundle.root).expect("validated contract includes coverage policy"); + let publish_crates = collect_unique_set( + &release.public_crates(), + if release.uses_classification() { + "classification.public" + } else { + "publish.crates" + }, + ) + .expect("validated contract enforces unique public crates"); + let required_crate_list = policy + .required_crates() + .expect("validated contract includes required crates"); + let required_crates = collect_unique_set(&required_crate_list, "required.crates") + .expect("validated contract enforces unique required.crates"); + validate_publish_package_metadata(workspace_root, &publish_crates)?; + validate_required_coverage_summary(workspace_root, &required_crates, policy.thresholds())?; + Ok(()) +} + +fn validate_contract_bundle_with_release_policy_override( + bundle: &ContractBundle, + release_policy_override: Option<PathBuf>, +) -> Result<(), String> { + if bundle.manifest.contract.name.trim().is_empty() { + return Err("contract name is required".to_string()); + } + if bundle.manifest.contract.version.trim().is_empty() { + return Err("contract version is required".to_string()); + } + if bundle.manifest.contract.source.trim().is_empty() { + return Err("contract source is required".to_string()); + } + if bundle.manifest.surface.model_crates.is_empty() { + return Err("contract surface.model_crates must not be empty".to_string()); + } + if bundle.manifest.surface.algorithm_crates.is_empty() { + return Err("contract surface.algorithm_crates must not be empty".to_string()); + } + if bundle.manifest.surface.wasm_crates.is_empty() { + return Err("contract surface.wasm_crates must not be empty".to_string()); + } + if bundle.exports.is_empty() { + return Err("at least one language export mapping is required".to_string()); + } + for mapping in &bundle.exports { + if mapping.language.id.trim().is_empty() { + return Err("language.id is required".to_string()); + } + if mapping.language.repository.trim().is_empty() { + return Err(format!( + "language.repository is required for {}", + mapping.language.id + )); + } + if mapping.packages.is_empty() { + return Err(format!( + "packages map is required for {}", + mapping.language.id + )); + } + if mapping.language.id == "ts" { + let artifacts = match mapping.artifacts.as_ref() { + Some(artifacts) => artifacts, + None => return Err("artifacts map is required for ts".to_string()), + }; + if artifacts + .models_dir + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + || artifacts + .constants_dir + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + || artifacts + .wasm_dist_dir + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + || artifacts + .manifest_file + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err("artifacts fields must be non-empty for ts".to_string()); + } + } + } + if bundle.version.contract.version.trim().is_empty() { + return Err("version.contract.version is required".to_string()); + } + if bundle.version.contract.stability.trim().is_empty() { + return Err("version.contract.stability is required".to_string()); + } + if bundle.version.semver.major_on.is_empty() + || bundle.version.semver.minor_on.is_empty() + || bundle.version.semver.patch_on.is_empty() { - return Err( - "coverage policy must enforce 100/100/100/100 with required branches".to_string(), - ); + return Err("version.semver rules must all be non-empty".to_string()); } - - let required_packages = policy - .required_crate_entries() - .iter() - .cloned() - .collect::<BTreeSet<_>>(); - if workspace_packages != required_packages { - let missing = workspace_packages - .difference(&required_packages) - .cloned() - .collect::<BTreeSet<_>>(); - let extra = required_packages - .difference(&workspace_packages) - .cloned() - .collect::<BTreeSet<_>>(); - return Err(format!( - "coverage policy missing workspace crates: {}; coverage policy includes unknown crates: {}", - join_set(&missing), - join_set(&extra) - )); + if !bundle.version.compatibility.requires_conformance_pass { + return Err("compatibility.requires_conformance_pass must be true".to_string()); } - - Ok(()) -} - -fn publish_config_is_public(publish: Option<&PackagePublish>) -> bool { - matches!( - publish, - Some(PackagePublish::Registries(registries)) - if registries.len() == 1 && registries[0] == "crates-io" + if !bundle.version.compatibility.requires_export_manifest_diff { + return Err("compatibility.requires_export_manifest_diff must be true".to_string()); + } + if !bundle.version.compatibility.requires_release_notes { + return Err("compatibility.requires_release_notes must be true".to_string()); + } + if !bundle.manifest.policy.exclude_internal_workspace_crates + || !bundle.manifest.policy.require_reproducible_exports + || !bundle.manifest.policy.require_conformance_vectors + { + return Err("contract policy flags must all be true".to_string()); + } + let workspace_root = bundle + .root + .parent() + .expect("contract root must have a workspace parent"); + if let Some(operations_manifest) = bundle.operations_manifest.as_ref() { + validate_operations_contract(bundle, operations_manifest, workspace_root)?; + } + validate_core_unit_dimension_variant_order(workspace_root)?; + validate_coverage_policy_parity(workspace_root, &bundle.root)?; + if resolve_release_contract_path_with_override( + workspace_root, + &bundle.root, + release_policy_override.clone(), ) + .expect("validated release contract path resolution should not fail") + .is_some() + { + validate_release_publish_policy_with_override( + workspace_root, + &bundle.root, + bundle.version.contract.version.as_str(), + release_policy_override, + )?; + } + Ok(()) } -fn publish_config_is_non_public(publish: Option<&PackagePublish>) -> bool { - matches!(publish, Some(PackagePublish::Bool(false))) -} - -fn validate_release_publish_policy( +fn validate_release_publish_policy_with_override( workspace_root: &Path, contract_root: &Path, contract_version: &str, + release_policy_override: Option<PathBuf>, ) -> Result<(), String> { - let release = load_release_contract(workspace_root, contract_root)?; + let release = load_release_contract_with_override( + workspace_root, + contract_root, + release_policy_override, + )?; if release.release.version.trim().is_empty() { return Err("release.version must not be empty".to_string()); } @@ -915,35 +1677,98 @@ fn validate_release_publish_policy( Ok(()) } -pub fn validate_release_preflight(workspace_root: &Path) -> Result<(), String> { +#[cfg(test)] +pub fn synthetic_release_policy_for_workspace(workspace_root: &Path) -> Result<String, String> { let bundle = load_contract_bundle(workspace_root)?; - validate_contract_bundle(&bundle)?; - let release = load_release_contract(workspace_root, &bundle.root)?; - let policy = - load_coverage_policy(&bundle.root).expect("validated contract includes coverage policy"); - let publish_crates = collect_unique_set( - &release.public_crates(), - if release.uses_classification() { - "classification.public" + let publish_configs = workspace_package_publish_configs(workspace_root)?; + let dependencies = read_workspace_package_dependencies(workspace_root)?; + + let mut public = BTreeSet::new(); + let mut internal = BTreeSet::new(); + for (crate_name, publish) in &publish_configs { + if publish_config_is_public(publish.as_ref()) { + public.insert(crate_name.clone()); } else { - "publish.crates" - }, - ) - .expect("validated contract enforces unique public crates"); - let required_crate_list = policy - .required_crates() - .expect("validated contract includes required crates"); - let required_crates = collect_unique_set(&required_crate_list, "required.crates") - .expect("validated contract enforces unique required.crates"); - validate_publish_package_metadata(workspace_root, &publish_crates)?; - validate_required_coverage_summary(workspace_root, &required_crates, policy.thresholds())?; - Ok(()) + internal.insert(crate_name.clone()); + } + } + + let mut in_degree = BTreeMap::new(); + let mut dependents = BTreeMap::<String, BTreeSet<String>>::new(); + for crate_name in &public { + in_degree.insert(crate_name.clone(), 0usize); + dependents.insert(crate_name.clone(), BTreeSet::new()); + } + for crate_name in &public { + for dep in &dependencies[crate_name] { + if !public.contains(dep) { + continue; + } + *in_degree + .get_mut(crate_name) + .expect("public crate present in indegree map") += 1; + dependents + .get_mut(dep) + .expect("public dependency present in dependents map") + .insert(crate_name.clone()); + } + } + + let mut ready = in_degree + .iter() + .filter(|(_, degree)| **degree == 0) + .map(|(crate_name, _)| crate_name.clone()) + .collect::<BTreeSet<_>>(); + let mut publish_order = Vec::new(); + while let Some(crate_name) = ready.pop_first() { + publish_order.push(crate_name.clone()); + for dependent in dependents[&crate_name].clone() { + let degree = in_degree + .get_mut(&dependent) + .expect("dependent crate present in indegree map"); + *degree -= 1; + if *degree == 0 { + ready.insert(dependent); + } + } + } + if publish_order.len() != public.len() { + return Err("public crate dependency graph contains a cycle".to_string()); + } + + let public = public.into_iter().collect::<Vec<_>>(); + let internal = internal.into_iter().collect::<Vec<_>>(); + Ok(format!( + "[release]\nversion = \"{}\"\n\n[classification]\npublic = {}\ninternal = {}\ndeferred = []\nretired = []\nyank_only = []\n\n[publish_order]\ncrates = {}\n", + bundle.version.contract.version, + toml_inline_array(&public), + toml_inline_array(&internal), + toml_inline_array(&publish_order), + )) +} + +#[cfg(test)] +fn toml_inline_array(values: &[String]) -> String { + let joined = values + .iter() + .map(|value| format!("\"{value}\"")) + .collect::<Vec<_>>() + .join(", "); + format!("[{joined}]") } pub fn load_contract_bundle(workspace_root: &Path) -> Result<ContractBundle, String> { let root = contract_root(workspace_root); let manifest = parse_toml::<ContractManifest>(&root.join("manifest.toml"))?; let version = parse_toml::<VersionPolicy>(&root.join("version.toml"))?; + let operations_manifest_path = root.join("operations.toml"); + let operations_manifest = if operations_manifest_path.is_file() { + Some(parse_toml::<OperationsContractManifest>( + &operations_manifest_path, + )?) + } else { + None + }; let exports_dir = root.join("exports"); let mut exports = Vec::new(); let read_dir = match fs::read_dir(&exports_dir) { @@ -959,14 +1784,39 @@ pub fn load_contract_bundle(workspace_root: &Path) -> Result<ContractBundle, Str } exports.push(parse_toml::<ExportMapping>(&path)?); } + let sdk_exports = load_sdk_exports(&root)?; Ok(ContractBundle { root, manifest, version, exports, + operations_manifest, + sdk_exports, }) } +fn load_sdk_exports(contract_root: &Path) -> Result<Vec<SdkExportMapping>, String> { + let exports_dir = contract_root.join("sdk-exports"); + if !exports_dir.exists() { + return Ok(Vec::new()); + } + let read_dir = match fs::read_dir(&exports_dir) { + Ok(read_dir) => read_dir, + Err(e) => return Err(format!("read dir {}: {e}", exports_dir.display())), + }; + let mut entries = read_dir.filter_map(Result::ok).collect::<Vec<_>>(); + entries.sort_by_key(|entry| entry.file_name()); + let mut mappings = Vec::new(); + for entry in entries { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("toml") { + continue; + } + mappings.push(parse_toml::<SdkExportMapping>(&path)?); + } + Ok(mappings) +} + pub fn validate_contract_bundle(bundle: &ContractBundle) -> Result<(), String> { if bundle.manifest.contract.name.trim().is_empty() { return Err("contract name is required".to_string()); @@ -1062,6 +1912,9 @@ pub fn validate_contract_bundle(bundle: &ContractBundle) -> Result<(), String> { .root .parent() .expect("contract root must have a workspace parent"); + if let Some(operations_manifest) = bundle.operations_manifest.as_ref() { + validate_operations_contract(bundle, operations_manifest, workspace_root)?; + } validate_core_unit_dimension_variant_order(workspace_root)?; validate_coverage_policy_parity(workspace_root, &bundle.root)?; if resolve_release_contract_path(workspace_root, &bundle.root) @@ -1252,6 +2105,147 @@ crates = ["radroots_a"] root } + fn add_operation_contract_files(root: &Path) { + write_file( + &root.join("contract").join("operations.toml"), + r#"[contract] +name = "radroots_contract" +version = "1.0.0" +source = "synthetic" + +[public] +domains = ["profile", "farm", "listing", "trade"] + +[shared_types] +public = [ + "WireEventParts", + "UnsignedEventDraft", + "RadrootsNostrEvent", + "RadrootsNostrEventRef", + "RadrootsNostrEventPtr", + "RadrootsTradeListingAddress", + "RadrootsProfile", + "RadrootsFarm", + "RadrootsListing", +] + +[errors] +classes = ["encode_error", "parse_error", "validation_error", "address_error"] + +[implementation_provenance] +model_crates = ["radroots_a"] +algorithm_crates = ["radroots_b"] +wasm_crates = ["radroots_a_wasm"] + +[operations.profile_build_draft] +domain = "profile" +id = "profile.build_draft" +stability = "beta" +inputs = ["RadrootsProfile", "RadrootsProfileType?"] +outputs = ["WireEventParts"] +error_class = "encode_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.profile_build_draft.implementation] +rust_modules = ["crates/core/src/unit.rs"] +rust_types = ["radroots_events::profile::RadrootsProfile"] + +[operations.profile_build_draft.conformance] +vector = "conformance/vectors/profile/build_draft.v1.json" + +[operations.listing_build_draft] +domain = "listing" +id = "listing.build_draft" +stability = "beta" +inputs = ["RadrootsListing"] +outputs = ["WireEventParts"] +error_class = "encode_error" +deterministic = true +signing = "native" +transport = "native" + +[operations.listing_build_draft.implementation] +rust_modules = ["crates/core/src/unit.rs"] +rust_types = ["radroots_events::listing::RadrootsListing"] + +[operations.listing_build_draft.conformance] +vector = "conformance/vectors/listing/build_draft.v1.json" +"#, + ); + write_file( + &root.join("contract").join("sdk-exports").join("ts.toml"), + r#"[language] +id = "ts" +repository = "sdk-typescript" + +[sdk] +package = "@radroots/sdk" +module_format = "esm" +deterministic_codec = "wasm" +signing = "native" +networking = "native" + +[operations] +"profile.build_draft" = "profile.buildDraft" +"listing.build_draft" = "listing.buildDraft" + +[shared_types] +"WireEventParts" = "WireEventParts" +"UnsignedEventDraft" = "UnsignedEventDraft" +"RadrootsNostrEvent" = "RadrootsNostrEvent" +"RadrootsListing" = "RadrootsListing" + +[artifacts] +models_dir = "src/generated" +runtime_dir = "src/runtime" +wasm_dist_dir = "dist" +manifest_file = "export-manifest.json" +"#, + ); + write_file( + &root + .join("conformance") + .join("vectors") + .join("profile") + .join("build_draft.v1.json"), + r#"{ + "suite": "profile", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "profile_build_draft_minimal_001", + "kind": "profile.build_draft", + "input": {}, + "expected": {} + } + ] +} +"#, + ); + write_file( + &root + .join("conformance") + .join("vectors") + .join("listing") + .join("build_draft.v1.json"), + r#"{ + "suite": "listing", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "listing_build_draft_minimal_001", + "kind": "listing.build_draft", + "input": {}, + "expected": {} + } + ] +} +"#, + ); + } + fn write_root_release_policy(root: &Path, raw: &str) { write_file(&root.join(ROOT_RELEASE_POLICY_RELATIVE), raw); } @@ -1312,6 +2306,15 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] } #[test] + fn validate_synthetic_operation_contract_bundle() { + let root = create_synthetic_workspace("operation_contract_bundle"); + add_operation_contract_files(&root); + let bundle = load_contract_bundle(&root).expect("load contract"); + validate_contract_bundle(&bundle).expect("validate contract"); + let _ = fs::remove_dir_all(root); + } + + #[test] fn ts_export_mapping_covers_model_and_wasm_surface() { let root = workspace_root(); let bundle = load_contract_bundle(&root).expect("load contract"); @@ -2406,6 +3409,45 @@ edition = "2024" } #[test] + fn validate_contract_bundle_reports_operation_contract_errors() { + let root = create_synthetic_workspace("operation_contract_bundle_errors"); + add_operation_contract_files(&root); + + let assert_bundle_error = |expected: &str, mutator: fn(&mut ContractBundle)| { + let mut bundle = load_contract_bundle(&root).expect("load bundle"); + mutator(&mut bundle); + let err = validate_contract_bundle(&bundle).expect_err("bundle validation error"); + assert!(err.contains(expected), "expected `{expected}` in `{err}`"); + }; + + assert_bundle_error("public.domains must not be empty", |bundle| { + bundle + .operations_manifest + .as_mut() + .expect("operations manifest") + .public + .domains + .clear(); + }); + assert_bundle_error( + "sdk-exports must define at least one operation-based language mapping", + |bundle| { + bundle.sdk_exports.clear(); + }, + ); + assert_bundle_error( + "sdk export ts must cover every public operation", + |bundle| { + bundle.sdk_exports[0] + .operations + .remove("listing.build_draft"); + }, + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] fn parse_toml_and_publish_flags_report_failures() { let missing = temp_root("parse_toml_missing"); let read_err = diff --git a/crates/xtask/src/export_ts.rs b/crates/xtask/src/export_ts.rs @@ -915,6 +915,8 @@ mod tests { }, }, exports: Vec::new(), + operations_manifest: None, + sdk_exports: Vec::new(), }; let mapping_err = ts_export_mapping(&bundle).expect_err("missing ts mapping"); assert!(mapping_err.contains("missing ts export mapping")); diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs @@ -245,6 +245,27 @@ mod tests { fs::write(path, content).expect("write file"); } + fn release_preflight_with_override(release_policy_path: Option<&Path>) -> Result<(), String> { + contract::validate_release_preflight_with_override( + &workspace_root(), + release_policy_path.map(PathBuf::from), + ) + } + + fn run_sdk_with_release_policy_override( + args: &[String], + release_policy_path: Option<&Path>, + ) -> Result<(), String> { + match args.first().map(String::as_str) { + Some("release") => match args.get(1).map(String::as_str) { + Some("preflight") => release_preflight_with_override(release_policy_path), + Some(other) => Err(format!("unknown release subcommand: {other}")), + None => Err("missing release subcommand".to_string()), + }, + _ => run_sdk(args), + } + } + fn create_synthetic_export_workspace(prefix: &str) -> PathBuf { let root = unique_temp_dir(prefix); fs::create_dir_all(&root).expect("create root"); @@ -543,6 +564,10 @@ crates = ["radroots_a"] let root = workspace_root(); let out_dir = unique_temp_dir("coverage_dispatch"); fs::create_dir_all(&out_dir).expect("create out dir"); + let release_policy_path = out_dir.join("publish-policy.toml"); + let release_policy = contract::synthetic_release_policy_for_workspace(&root) + .expect("synthetic release policy"); + write_file(&release_policy_path, &release_policy); let coverage_refresh_path = root .join("target") @@ -575,7 +600,7 @@ crates = ["radroots_a"] fs::write(&coverage_refresh_path, rows).expect("write coverage refresh"); validate_contract().expect("validate contract"); - release_preflight().expect("release preflight"); + release_preflight_with_override(Some(&release_policy_path)).expect("release preflight"); run_sdk(&["coverage".to_string(), "help".to_string()]).expect("coverage help"); run_sdk(&["coverage".to_string(), "required-crates".to_string()]) .expect("coverage required crates"); @@ -606,7 +631,11 @@ crates = ["radroots_a"] ]) .expect("coverage report"); - run_sdk(&["release".to_string(), "preflight".to_string()]).expect("sdk release preflight"); + run_sdk_with_release_policy_override( + &["release".to_string(), "preflight".to_string()], + Some(&release_policy_path), + ) + .expect("sdk release preflight"); run(&[ "sdk".to_string(),