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