lib

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

commit 2a3cf2042e25621b3d2a6183471170c5327aed2b
parent a68142d0090f24ced89d3ff9ac6fd994f8650885
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 23:47:52 +0000

contracts: centralize read-only owner APIs

- share unsigned listing validation with the verified event boundary
- expose non-migrating event-store status inspection
- preserve legacy counting and fail-closed corruption checks
- prove model/event and instance/pool parity

Diffstat:
MCHANGELOG.md | 14++++++++------
Mcontracts/releases/1.0.0-alpha.1.toml | 6++++++
Mcrates/event_store/README | 13+++++++++++++
Mcrates/event_store/src/lib.rs | 2+-
Mcrates/event_store/src/store.rs | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/trade/README | 2++
Mcrates/trade/src/operational_listing/mod.rs | 4++++
Mcrates/trade/src/operational_listing/validation.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
8 files changed, 225 insertions(+), 44 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -21,7 +21,9 @@ publish policy both pass for the same source revision. projection/head reads; projection cursors require an expected version and a monotonic prior-sequence compare-and-swap. Verified ephemeral events receive an explicit not-persisted outcome and allocate no raw sequence, tags, - observations, or heads. + observations, or heads. Read-only consumers can inspect the same fail-closed + status summary from an initialized pool without duplicating schema-sensitive + SQL or running migrations. - Nostr fetch-ingest receipts now distinguish admitted, unsupported, invalid, malformed, inserted, duplicate, and ephemeral not-persisted events, carry stable admission codes when classification occurs, and name valid-stream @@ -53,11 +55,11 @@ publish policy both pass for the same source revision. - Operational listing authoring now emits canonical Markdown content from the tag-authoritative model. Tolerant inbound JSON inspection remains a decode compatibility boundary only and is not an authoring format. -- Operational listing trade validation now requires a - `RadrootsSignatureVerifiedEvent` and rejects every non-operational - kind-`30402` marker partition before decoding. Event-store projection - reconstructs and verifies that typestate instead of trusting a plain stored - envelope. +- Operational listing trade validation exposes one shared unsigned-model + semantic reducer and a signature-verified event boundary that delegates to + it after kind, marker-partition, and decoding checks. Event-store projection + reconstructs and verifies the event typestate instead of trusting a plain + stored envelope. - Generic NIP-01 identifier and signature verification is now independent of knowledge decoding, and every dynamic Nostr kind conversion rejects values above `65535` instead of truncating them. Canonical-length author keys that diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -308,3 +308,9 @@ id = "outbox-ephemeral-event-policy" classification = "breaking" semver_impacts = ["add_enum_variant", "change_exported_algorithm_behavior"] summary = "Reject every NIP-16 ephemeral event from all generic durable-outbox entry points, keep transient events inside their owning live transport exchanges, and validate and configure every externally supplied SQLite pool connection before migration or writes." + +[[changes]] +id = "shared-read-only-owner-boundaries" +classification = "feature" +semver_impacts = ["add_exported_function"] +summary = "Expose one Operational Listing model-semantic validator shared by unsigned authoring tools and verified event validation, plus one non-migrating event-store status inspector shared by runtime and offline consumers." diff --git a/crates/event_store/README b/crates/event_store/README @@ -59,6 +59,19 @@ validates the declared backing mode, rejects multi-connection in-memory pools, and configures every file-pool connection with foreign-key enforcement and the required busy timeout before migrations or writes. +## Status inspection + +`inspect_event_store_status` reads status and validates stored classification +invariants from an existing `SqlitePool` without configuring the pool or +running migrations. `RadrootsEventStore::status_summary` delegates to this +same authority. This permits offline tools to inspect an already initialized +store without duplicating schema-sensitive SQL or turning a read operation into +a schema mutation. + +Pre-admission legacy rows remain included in `total_events` but excluded from +`valid_stream_events`. New-format rows with inconsistent verification, +admission, event-class, eligibility, or contract identity fail closed. + ## Projection cursors `projection_cursor` requires the caller's expected projection version. diff --git a/crates/event_store/src/lib.rs b/crates/event_store/src/lib.rs @@ -29,5 +29,5 @@ pub use model::{ #[cfg(feature = "sqlite")] pub use store::{ RADROOTS_EVENT_STORE_CONTRACT_QUERY_LIMIT_MAX, RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, - RadrootsEventStore, RadrootsTransportObservationRow, + RadrootsEventStore, RadrootsTransportObservationRow, inspect_event_store_status, }; diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -106,35 +106,7 @@ impl RadrootsEventStore { pub async fn status_summary( &self, ) -> Result<RadrootsEventStoreStatusSummary, RadrootsEventStoreError> { - let mut tx = self.pool.begin().await?; - let inconsistent_event_id: Option<String> = sqlx::query_scalar( - "SELECT event_id FROM event_envelopes WHERE contract_status NOT IN ('supported', 'unsupported_kind', 'unsupported_shape', 'ambiguous_shape') AND (verification_status != 'verified' OR contract_status NOT IN ('admitted', 'unsupported', 'invalid') OR kind < 0 OR kind > 65535 OR kind BETWEEN 20000 AND 29999 OR event_class IS NULL OR event_class != CASE WHEN kind = 0 OR kind = 3 OR kind BETWEEN 10000 AND 19999 THEN 'replaceable' WHEN kind BETWEEN 30000 AND 39999 THEN 'addressable' ELSE 'regular' END OR projection_eligible NOT IN (0, 1) OR projection_eligible != CASE WHEN contract_status = 'admitted' THEN 1 ELSE 0 END OR (contract_status = 'admitted') != (contract_id IS NOT NULL)) LIMIT 1", - ) - .fetch_optional(&mut *tx) - .await?; - if let Some(event_id) = inconsistent_event_id { - return Err( - RadrootsEventStoreError::StoredRawEventClassificationInconsistent { event_id }, - ); - } - let row = sqlx::query( - "SELECT COUNT(*) AS total_events, COALESCE(SUM(CASE WHEN verification_status = 'verified' AND contract_status = 'admitted' AND contract_id IS NOT NULL AND projection_eligible = 1 AND kind BETWEEN 0 AND 65535 AND NOT (kind BETWEEN 20000 AND 29999) AND event_class = CASE WHEN kind = 0 OR kind = 3 OR kind BETWEEN 10000 AND 19999 THEN 'replaceable' WHEN kind BETWEEN 30000 AND 39999 THEN 'addressable' ELSE 'regular' END THEN 1 ELSE 0 END), 0) AS valid_stream_events, MAX(seq) AS last_event_seq, MAX(updated_at_ms) AS last_event_updated_at_ms FROM event_envelopes", - ) - .fetch_one(&mut *tx) - .await?; - let transport_observations: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM event_transport_observation") - .fetch_one(&mut *tx) - .await?; - let summary = RadrootsEventStoreStatusSummary { - total_events: row.try_get("total_events")?, - valid_stream_events: row.try_get("valid_stream_events")?, - transport_observations, - last_event_seq: row.try_get("last_event_seq")?, - last_event_updated_at_ms: row.try_get("last_event_updated_at_ms")?, - }; - tx.commit().await?; - Ok(summary) + inspect_event_store_status(&self.pool).await } pub async fn ingest_event( @@ -644,6 +616,43 @@ impl RadrootsEventStore { } } +/// Inspects an existing event-store pool without configuring or migrating it. +/// +/// The inspection uses one read transaction and applies the same fail-closed +/// classification checks as [`RadrootsEventStore::status_summary`]. Callers +/// must supply a pool whose event-store schema has already been initialized. +pub async fn inspect_event_store_status( + pool: &SqlitePool, +) -> Result<RadrootsEventStoreStatusSummary, RadrootsEventStoreError> { + let mut tx = pool.begin().await?; + let inconsistent_event_id: Option<String> = sqlx::query_scalar( + "SELECT event_id FROM event_envelopes WHERE contract_status NOT IN ('supported', 'unsupported_kind', 'unsupported_shape', 'ambiguous_shape') AND (verification_status != 'verified' OR contract_status NOT IN ('admitted', 'unsupported', 'invalid') OR kind < 0 OR kind > 65535 OR kind BETWEEN 20000 AND 29999 OR event_class IS NULL OR event_class != CASE WHEN kind = 0 OR kind = 3 OR kind BETWEEN 10000 AND 19999 THEN 'replaceable' WHEN kind BETWEEN 30000 AND 39999 THEN 'addressable' ELSE 'regular' END OR projection_eligible NOT IN (0, 1) OR projection_eligible != CASE WHEN contract_status = 'admitted' THEN 1 ELSE 0 END OR (contract_status = 'admitted') != (contract_id IS NOT NULL)) LIMIT 1", + ) + .fetch_optional(&mut *tx) + .await?; + if let Some(event_id) = inconsistent_event_id { + return Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { event_id }); + } + let row = sqlx::query( + "SELECT COUNT(*) AS total_events, COALESCE(SUM(CASE WHEN verification_status = 'verified' AND contract_status = 'admitted' AND contract_id IS NOT NULL AND projection_eligible = 1 AND kind BETWEEN 0 AND 65535 AND NOT (kind BETWEEN 20000 AND 29999) AND event_class = CASE WHEN kind = 0 OR kind = 3 OR kind BETWEEN 10000 AND 19999 THEN 'replaceable' WHEN kind BETWEEN 30000 AND 39999 THEN 'addressable' ELSE 'regular' END THEN 1 ELSE 0 END), 0) AS valid_stream_events, MAX(seq) AS last_event_seq, MAX(updated_at_ms) AS last_event_updated_at_ms FROM event_envelopes", + ) + .fetch_one(&mut *tx) + .await?; + let transport_observations: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM event_transport_observation") + .fetch_one(&mut *tx) + .await?; + let summary = RadrootsEventStoreStatusSummary { + total_events: row.try_get("total_events")?, + valid_stream_events: row.try_get("valid_stream_events")?, + transport_observations, + last_event_seq: row.try_get("last_event_seq")?, + last_event_updated_at_ms: row.try_get("last_event_updated_at_ms")?, + }; + tx.commit().await?; + Ok(summary) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTransportObservationRow { pub event_id: String, @@ -2565,10 +2574,38 @@ mod tests { } #[tokio::test] + async fn pool_status_inspection_does_not_initialize_an_unmigrated_pool() { + let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("options"); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("pool"); + + assert!(matches!( + inspect_event_store_status(&pool).await, + Err(RadrootsEventStoreError::Sqlx(_)) + )); + let event_table_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'event_envelopes'", + ) + .fetch_one(&pool) + .await + .expect("schema inspection"); + assert_eq!(event_table_count, 0); + } + + #[tokio::test] async fn status_summary_counts_events_projections_and_transport_observations() { let store = RadrootsEventStore::open_memory().await.expect("open"); let empty = store.status_summary().await.expect("empty status"); + assert_eq!( + inspect_event_store_status(store.pool()) + .await + .expect("empty pool status"), + empty + ); assert_eq!(empty.total_events, 0); assert_eq!(empty.valid_stream_events, 0); assert_eq!(empty.transport_observations, 0); @@ -2598,6 +2635,14 @@ mod tests { .expect("observation ingest"); let status = store.status_summary().await.expect("status"); + sqlx::query("PRAGMA query_only = ON") + .execute(store.pool()) + .await + .expect("read-only connection"); + let inspected = inspect_event_store_status(store.pool()) + .await + .expect("read-only pool status"); + assert_eq!(inspected, status); assert_eq!(status.total_events, 1); assert_eq!(status.valid_stream_events, 1); assert_eq!(status.transport_observations, 1); @@ -2631,6 +2676,10 @@ mod tests { store.status_summary().await, Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. }) )); + assert!(matches!( + inspect_event_store_status(store.pool()).await, + Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. }) + )); sqlx::query( "UPDATE event_envelopes SET projection_eligible = 1, verification_status = 'signature_invalid' WHERE event_id = ?", @@ -2975,6 +3024,10 @@ mod tests { Err(RadrootsEventStoreError::StoredRawEventRequiresReconciliation { .. }) )); let status = store.status_summary().await.expect("legacy status"); + let inspected = inspect_event_store_status(store.pool()) + .await + .expect("legacy pool status"); + assert_eq!(inspected, status); assert_eq!(status.total_events, 1); assert_eq!(status.valid_stream_events, 0); } diff --git a/crates/trade/README b/crates/trade/README @@ -9,6 +9,8 @@ helpers for the `radroots` core libraries. money, quantity, currency, and unit values; * listing publish helpers for canonical address normalization, author checks, and event-shaping workflows; + * one typed Operational Listing semantic reducer shared by unsigned model + validation and the signature-verified Nostr event boundary; * order and public-trade helpers for shared request validation and projection; * optional `serde` and `serde_json` support for shared model serialization; * portable shared-model support for both `std` and `no_std` builds. diff --git a/crates/trade/src/operational_listing/mod.rs b/crates/trade/src/operational_listing/mod.rs @@ -24,6 +24,10 @@ pub use self::mutation::{ RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutation, RadrootsOperationalListingMutationError, }; +pub use self::validation::{ + RadrootsOperationalListingTradeProjection, validate_operational_listing_event, + validate_operational_listing_model, +}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsClassifiedListingAddressParts { pub address: RadrootsClassifiedListingAddress, diff --git a/crates/trade/src/operational_listing/validation.rs b/crates/trade/src/operational_listing/validation.rs @@ -11,8 +11,8 @@ use radroots_core::{ }; use radroots_event::{ classified_listing::{RadrootsClassifiedListingPartition, classify_classified_listing_tags}, - ids::RadrootsClassifiedListingAddress, - kinds::is_classified_listing_kind, + ids::{RadrootsClassifiedListingAddress, RadrootsPublicKey}, + kinds::{KIND_CLASSIFIED_LISTING, is_classified_listing_kind}, location::{has_textual_locality, is_public_geohash5}, operational_listing::{ RadrootsOperationalListing, RadrootsOperationalListingAvailability, @@ -75,13 +75,29 @@ pub fn validate_operational_listing_event( let listing = operational_listing_from_nostr_event(event) .map_err(|error| OperationalListingValidationError::ParseError { error })?; + validate_operational_listing_model(listing, event.author()) +} + +/// Validates the trade semantics of an unsigned Operational Listing model. +/// +/// The seller is typed independently from the model because it is the +/// authority against which the listing's farm identity is checked. This +/// function does not perform event-kind, profile, decoding, or signature +/// checks; callers handling Nostr events must use +/// [`validate_operational_listing_event`] instead. +pub fn validate_operational_listing_model( + listing: RadrootsOperationalListing, + seller_pubkey: &RadrootsPublicKey, +) -> Result<RadrootsOperationalListingTradeProjection, OperationalListingValidationError> { let listing_id = listing.d_tag.trim().to_string(); - let seller_pubkey = event.author_str().to_string(); - if listing.farm.pubkey != seller_pubkey { + if listing.farm.pubkey != seller_pubkey.as_str() { return Err(OperationalListingValidationError::InvalidSeller); } - let listing_addr_raw = format!("{}:{}:{}", event.kind_u32(), seller_pubkey, listing_id); + let listing_addr_raw = format!( + "{KIND_CLASSIFIED_LISTING}:{}:{listing_id}", + seller_pubkey.as_str() + ); let listing_addr = RadrootsClassifiedListingAddress::parse(&listing_addr_raw) .expect("validated listing identity must form a listing address"); @@ -175,7 +191,7 @@ pub fn validate_operational_listing_event( Ok(RadrootsOperationalListingTradeProjection { listing_id, listing_addr, - seller_pubkey, + seller_pubkey: seller_pubkey.as_str().to_string(), title, description, product_type, @@ -206,7 +222,10 @@ fn validate_listing_location_geohash( #[cfg(test)] mod tests { - use super::{OperationalListingValidationError, validate_operational_listing_event}; + use super::{ + OperationalListingValidationError, validate_operational_listing_event, + validate_operational_listing_model, + }; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, @@ -215,7 +234,7 @@ mod tests { use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId}, + ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsPublicKey}, kinds::KIND_CLASSIFIED_LISTING, operational_listing::{ RadrootsOperationalListing, RadrootsOperationalListingAvailability, @@ -241,6 +260,14 @@ mod tests { RadrootsInventoryBinId::parse(raw).expect("bin id") } + fn seller_pubkey() -> RadrootsPublicKey { + RadrootsPublicKey::parse(SELLER).expect("seller pubkey") + } + + fn other_seller_pubkey() -> RadrootsPublicKey { + RadrootsPublicKey::parse(OTHER_SELLER).expect("other seller pubkey") + } + fn base_listing() -> RadrootsOperationalListing { RadrootsOperationalListing { d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), @@ -450,6 +477,80 @@ mod tests { } #[test] + #[cfg(feature = "serde_json")] + fn model_and_verified_event_validation_return_the_same_projection() { + let listing = base_listing(); + let event_projection = + validate_operational_listing_event(&base_event(&listing)).expect("event projection"); + let model_projection = validate_operational_listing_model(listing, &seller_pubkey()) + .expect("model projection"); + + assert_eq!( + serde_json::to_value(model_projection).expect("model projection JSON"), + serde_json::to_value(event_projection).expect("event projection JSON") + ); + } + + #[test] + fn model_and_verified_event_validation_return_the_same_semantic_errors() { + let mut listing = base_listing(); + listing.inventory_available = None; + let event_error = validate_operational_listing_event(&base_event(&listing)) + .expect_err("event inventory error"); + let model_error = validate_operational_listing_model(listing, &seller_pubkey()) + .expect_err("model inventory error"); + assert_eq!(model_error, event_error); + + let listing = base_listing(); + let event = event_with_parts( + OTHER_SELLER, + KIND_CLASSIFIED_LISTING, + base_event(&listing).event().tags_as_vec(), + String::new(), + ); + let event_error = + validate_operational_listing_event(&event).expect_err("event seller error"); + let model_error = validate_operational_listing_model(listing, &other_seller_pubkey()) + .expect_err("model seller error"); + assert_eq!(model_error, event_error); + } + + #[test] + fn model_validation_reports_errors_before_event_encoding() { + let mut listing = base_listing(); + listing.bins.clear(); + assert_eq!( + validate_operational_listing_model(listing, &seller_pubkey()) + .expect_err("missing bins"), + OperationalListingValidationError::MissingBins + ); + + let mut listing = base_listing(); + listing.primary_bin_id = bin_id("missing"); + assert_eq!( + validate_operational_listing_model(listing, &seller_pubkey()) + .expect_err("missing primary bin"), + OperationalListingValidationError::MissingPrimaryBin + ); + + let mut listing = base_listing(); + listing.location.as_mut().expect("location").geohash = " ".into(); + assert_eq!( + validate_operational_listing_model(listing, &seller_pubkey()) + .expect_err("missing geohash"), + OperationalListingValidationError::MissingLocationGeohash + ); + + let mut listing = base_listing(); + listing.location.as_mut().expect("location").geohash = "9q8yyz".into(); + assert_eq!( + validate_operational_listing_model(listing, &seller_pubkey()) + .expect_err("invalid geohash"), + OperationalListingValidationError::InvalidLocationGeohash + ); + } + + #[test] fn validate_listing_rejects_retired_kind() { let listing = base_listing(); let event = event_with_parts(