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:
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(),