commit 1854f6167578b526f394ac78dae799a7c2383ad0
parent f614df95bcc7ed795d63f892d64870531384cb62
Author: triesap <tyson@radroots.org>
Date: Sun, 19 Jul 2026 07:55:16 +0000
event: seal food publication and replica routing
- add sealed FoodAvailability signing and publication with Blossom media boundaries
- require verified Operational Listing validation and signed conformance profiles
- make classified-listing replica routing raw-head-first and projection-atomic
- govern release, replica, feature, and contract metadata for the new boundary
Diffstat:
31 files changed, 1381 insertions(+), 173 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -27,6 +27,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.
- 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
@@ -81,8 +86,19 @@ publish policy both pass for the same source revision.
ordered image diagnostics, and excludes generic or operational listings.
Strict revision comparison revalidates both signed events against authored
wire semantics before enforcing a stable coordinate and `published_at` plus
- NIP-01 replacement ordering. This adds no replica, signing, publication,
- upload, raster-decoding, or network-availability claim.
+ NIP-01 replacement ordering.
+- Focused FoodAvailability signing and client publication now use a sealed
+ Nostr builder with a construction-time timestamp and no raw mutation escape.
+ Generic signing and client publication reject focused or mixed kind-`30402`
+ profiles before signer access; signed-event relay remains transport-only.
+ Typed signing and publication do not attest BUD-02 upload completion.
+- Legacy replica ingestion now verifies kind-`30402` signatures, selects the
+ raw addressable head before profile decoding, and sends only the Operational
+ Listing partition to its trade-product projection. Selected focused/generic
+ exclusions and invalid/ambiguous rejections remove an older projection while
+ advancing the head, preventing stale projection fallback. The public
+ head-only helper rejects kind `30402`; callers must use profile-aware
+ ingestion so the head and projection remain atomic.
- Event-contract identification now selects Operational Listing only for its
raw marker partition. Focused FoodAvailability is admission-only, while
marker-free generic and mixed-marker NIP-99 events cannot be mislabeled as
diff --git a/Cargo.lock b/Cargo.lock
@@ -4478,6 +4478,7 @@ dependencies = [
"radroots_event",
"radroots_event_codec",
"radroots_identity",
+ "radroots_test_fixtures",
"reqwest",
"serde",
"serde_json",
@@ -4627,12 +4628,15 @@ version = "1.0.0-alpha.1"
dependencies = [
"base64 0.22.1",
"hex",
+ "nostr",
"radroots_core",
"radroots_event",
"radroots_event_codec",
+ "radroots_nostr",
"radroots_replica_schema",
"radroots_replica_store",
"radroots_sql_core",
+ "radroots_test_fixtures",
"serde",
"serde_json",
"sha2",
@@ -4860,6 +4864,7 @@ dependencies = [
"radroots_event_codec",
"radroots_event_store",
"radroots_nostr",
+ "radroots_test_fixtures",
"radroots_transport",
"serde",
"serde_json",
diff --git a/contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json b/contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json
@@ -217,6 +217,86 @@
},
"message": "missing listing inventory"
}
+ },
+ {
+ "id": "trade_validation_validate_operational_listing_event_focused_profile_004",
+ "kind": "trade_validation.validate_operational_listing_event.invalid",
+ "input": {
+ "event": {
+ "id": "854bbadc6834830ba084fa1e85a40361d196b12e743e0c466675816bfcd90462",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1700000100,
+ "kind": 30402,
+ "tags": [
+ ["d", "food-focused"],
+ ["title", "Nantes Carrots"],
+ ["summary", "Fresh bunches"],
+ ["published_at", "1700000000"],
+ ["location", "Central Saanich, BC"],
+ ["price", "3", "CAD"],
+ ["radroots:price_unit", "lb"],
+ ["status", "active"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "bd96b50265a55196efd5cf9c9d81389f7ec9ab4fcbc6b69babd8c8bba05fdae5f4d8f0d813c22a2dd9dba1582a4fc2d1256b3a4df0d954bb4e9a496c6256b714"
+ }
+ },
+ "expected": {
+ "error": {
+ "kind": "invalid_profile"
+ },
+ "message": "classified listing is not an Operational Listing profile"
+ }
+ },
+ {
+ "id": "trade_validation_validate_operational_listing_event_generic_nip99_005",
+ "kind": "trade_validation.validate_operational_listing_event.invalid",
+ "input": {
+ "event": {
+ "id": "a0ec5543e05cb0a70b8dbd8b94a92b4880145ce072b3843499c1216c3be0491c",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1700000100,
+ "kind": 30402,
+ "tags": [
+ ["d", "generic-nip99"],
+ ["title", "Generic produce listing"],
+ ["price", "3", "CAD"]
+ ],
+ "content": "Standards-compatible marker-free listing.",
+ "sig": "eaef63ec0633a825d7534adf635b1c7910992dabbbc51f74986d3bffb15a5e3a1e62b5310e59cb808941029ee6252b80b7011ee18d57e1ce71c87dfc2193e08c"
+ }
+ },
+ "expected": {
+ "error": {
+ "kind": "invalid_profile"
+ },
+ "message": "classified listing is not an Operational Listing profile"
+ }
+ },
+ {
+ "id": "trade_validation_validate_operational_listing_event_ambiguous_profile_006",
+ "kind": "trade_validation.validate_operational_listing_event.invalid",
+ "input": {
+ "event": {
+ "id": "836ab6059aba9cbf2cae36b59b2b0573db8b9f8b109e41a0aaba81f5723d2d02",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1700000100,
+ "kind": 30402,
+ "tags": [
+ ["d", "mixed-markers"],
+ ["radroots:price_unit", "lb"],
+ ["radroots:primary_bin", "bin-a"]
+ ],
+ "content": "Mixed marker listing.",
+ "sig": "a7039386c4a976904bf14265c8c9c73d602a0e68457715a8af90feeee5e396d98e3874eaa2d58be9d02d7cd2616c7a411ef8057dd2b8266e40504dbec004ce9c"
+ }
+ },
+ "expected": {
+ "error": {
+ "kind": "invalid_profile"
+ },
+ "message": "classified listing is not an Operational Listing profile"
+ }
}
]
}
diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md
@@ -76,11 +76,22 @@ then applies NIP-01 replacement order: later `created_at` wins, and the lower
event id wins at equal time. Side-specific errors identify an invalid previous
or current candidate before revision comparison.
-No operation in this boundary signs, publishes, retrieves, or replicates an
-event. A validated authored image proves local Blossom descriptor-to-byte
-agreement only; successful BUD-02 upload completion and any raster,
-retrievability, or availability checks remain runtime responsibilities before
-signing.
+The `radroots_nostr` `events` feature seals strict FoodAvailability wire parts
+behind a builder whose timestamp cannot be mutated after validation. It supports
+local signing and typed client publication; generic authoring rejects focused
+and mixed kind-`30402` profiles. Signed-event relay remains transport-only.
+Legacy replica ingestion verifies NIP-01 first, selects the raw addressable head
+before profile decoding, and routes only Operational Listing into its
+trade-product projection. Selected focused or generic exclusions and invalid or
+ambiguous rejections remove an older operational projection and still advance
+the raw head. The head-only replica helper rejects kind `30402`; these events
+require profile-aware ingestion so projection cleanup and head movement remain
+atomic.
+
+A validated authored image proves local Blossom descriptor-to-byte agreement
+only. Successful BUD-02 upload completion and any raster, retrievability, or
+availability checks remain runtime responsibilities before signing or
+publication.
## Kind-1 post boundary rule
@@ -131,7 +142,7 @@ implementation.
| document | 30361 | RadrootsDocument | events.document.publish, events.document.list, events.document.get | requires `d` and pubkey tags; optional address tag |
| resource_area | 30370 | RadrootsResourceArea | events.resource_area.publish, events.resource_area.list, events.resource_area.get | addressable; GCS location and `g` tag required |
| resource_cap | 30371 | RadrootsResourceHarvestCap | events.resource_cap.publish, events.resource_cap.list, events.resource_cap.get | addressable; required address, pubkey, key, start, and end tags |
-| food_availability | 30402 | RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection / RadrootsAdmittedFoodAvailabilityEvent | food_availability.build_authored_draft, food_availability.project_verified_event, food_availability.verify_and_admit_event, food_availability.validate_revision | focused `radroots.food.availability.v1` profile; strict deterministic authoring, verified projection/admission, explicit non-focused exclusion, and stable-coordinate revision validation; no signing, transport, replica, client behavior, or publication operation |
+| food_availability | 30402 | RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection / RadrootsAdmittedFoodAvailabilityEvent / RadrootsNostrFoodAvailabilityEventBuilder | food_availability.build_authored_draft, food_availability.project_verified_event, food_availability.verify_and_admit_event, food_availability.validate_revision | focused `radroots.food.availability.v1` profile; strict deterministic authoring, verified projection/admission, stable-coordinate revision validation, sealed Nostr signing/publication, generic-authoring reservation, and raw-head-first legacy replica partitioning; BUD-02 upload evidence remains a runtime prerequisite |
| operational_listing | 30402 | RadrootsOperationalListing | events.operational_listing.publish, events.operational_listing.list, events.operational_listing.get | NIP-99 classified-listing kind with the richer Radroots operational profile; canonical Markdown content and tags; farm author required |
| dvm_request | 5000-5999 | RadrootsJobRequest | events.dvm_request.publish, events.dvm_request.list, events.dvm_request.get | generic DVM request surface |
| dvm_result | 6000-6999 | RadrootsJobResult | events.dvm_result.publish, events.dvm_result.list, events.dvm_result.get | generic DVM result surface |
diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md
@@ -259,8 +259,31 @@ Revision validation accepts two independently signature-verified events and re-a
authored wire profile to each side; tolerant normalized or diagnostic-bearing projections cannot
enter this comparison. Kind, author, `d`, and `published_at` must remain stable. The candidate must
have a later `created_at`, or the lower event id when both timestamps are equal. Invalid previous
-and current inputs have side-specific errors. The profile contract does not implement signing,
-relay publication, replica selection, media upload, or client behavior.
+and current inputs have side-specific errors.
+
+With the `events` feature, `radroots_nostr_build_food_availability_event` fixes `created_at` during
+typed construction, derives the exact strict wire parts, and returns a sealed builder with no raw
+tag, content, or timestamp mutation. The builder supports local signing and, with the `client`
+feature, typed relay publication. Generic builder signing and client publication reject focused and
+mixed-marker kind-`30402` events before signer access. Marker-free generic NIP-99 and
+operational-only builders remain available for explicit compatibility, while relaying an already
+signed event remains transport-only and establishes no Radroots authoring claim.
+
+Legacy replica ingestion verifies kind-`30402` identifiers and signatures before acquiring its
+write transaction, then selects the raw addressable head before profile decoding. Only the
+Operational Listing partition can reach the legacy trade-product projection. A signature-valid,
+coordinate-valid, selected focused event or marker-free generic NIP-99 event advances the raw head
+as excluded; selected invalid focused, mixed-marker, and malformed operational profiles advance it
+as rejected. Every selected excluded or rejected replacement removes an older operational
+projection, so stale events cannot resurrect it. A signature failure changes neither the raw head
+nor the projection; a missing or invalid `d` tag fails before an addressable head can be selected.
+The public head-only helper rejects kind `30402`, which must use profile-aware ingestion so head and
+projection changes remain atomic.
+
+The typed Nostr boundary does not prove BUD-02 upload completion. Every media-bearing caller must
+obtain successful Blossom upload evidence before signing or publishing; byte-verified descriptors
+alone prove only local descriptor-to-byte agreement. Typed outbox persistence and upload-evidence
+bridging remain separate runtime responsibilities.
### Calendar Trust Layers
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -1424,11 +1424,11 @@ vector = "contracts/conformance/vectors/trade/reduce_records.v1.json"
domain = "trade_validation"
id = "trade_validation.validate_operational_listing_event"
stability = "beta"
-inputs = ["RadrootsEventEnvelope"]
+inputs = ["RadrootsSignatureVerifiedEvent"]
outputs = ["RadrootsOperationalListingTradeProjection"]
error_class = "validation_error"
deterministic = true
-signing = "native"
+signing = "none"
transport = "native"
[operations.trade_validation_validate_operational_listing_event.implementation]
@@ -1437,7 +1437,7 @@ rust_modules = [
"crates/trade/src/operational_listing/validation.rs",
]
rust_types = [
- "radroots_event::RadrootsEventEnvelope",
+ "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent",
"radroots_event::trade_validation::RadrootsOperationalListingValidationError",
"radroots_trade::operational_listing::validation::RadrootsOperationalListingTradeProjection",
]
diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml
@@ -168,3 +168,17 @@ semver_impacts = [
"change_exported_algorithm_behavior",
]
summary = "Add typed FoodAvailability encoding, signature-gated tolerant admission, strict signed-revision comparison, and partition-aware kind-30402 contract identification while advancing the event registry to version 4."
+
+[[changes]]
+id = "food-availability-publication-replica-boundary"
+classification = "breaking"
+semver_impacts = [
+ "add_exported_type",
+ "add_exported_function",
+ "add_exported_field",
+ "add_enum_variant",
+ "add_conformance_vector",
+ "change_exported_function_signature",
+ "change_exported_algorithm_behavior",
+]
+summary = "Reserve focused FoodAvailability signing and publication behind a sealed typed Nostr builder, require verified Operational Listing validation input, route replica kind-30402 heads before profile projection, and reject head-only kind-30402 ingestion."
diff --git a/contracts/replica.toml b/contracts/replica.toml
@@ -16,6 +16,11 @@ deterministic_emit_and_ingest = true
forbid_legacy_alias_identifiers = true
profile_event_emission = "excluded"
unknown_sync_request_fields = "reject"
+classified_listing_signature_verification = "required_before_state"
+classified_listing_head_selection = "raw_before_profile"
+classified_listing_operational_projection = "operational_partition_only"
+classified_listing_excluded_or_rejected_head = "remove_projection_and_advance"
+classified_listing_head_only_ingest = "reject_require_profile_aware"
[transfer]
version = 2
diff --git a/contracts/version.toml b/contracts/version.toml
@@ -21,6 +21,7 @@ major_on = [
]
minor_on = [
"add_exported_type",
+ "add_exported_function",
"add_exported_constant",
"add_public_foundation_crate",
"add_optional_field",
diff --git a/crates/event/src/classified_listing.rs b/crates/event/src/classified_listing.rs
@@ -38,7 +38,7 @@ pub fn classify_classified_listing_tags(
pub fn classify_classified_listing_tag_slice(
tags: &[RadrootsEventTag],
) -> RadrootsClassifiedListingPartition {
- classify_classified_listing_tag_names(
+ classify_classified_listing_marker_names(
tags.iter()
.map(|tag| tag.as_slice().first().map(String::as_str)),
)
@@ -47,10 +47,11 @@ pub fn classify_classified_listing_tag_slice(
pub(crate) fn classify_classified_listing_raw_tags(
tags: &[Vec<String>],
) -> RadrootsClassifiedListingPartition {
- classify_classified_listing_tag_names(tags.iter().map(|tag| tag.first().map(String::as_str)))
+ classify_classified_listing_marker_names(tags.iter().map(|tag| tag.first().map(String::as_str)))
}
-fn classify_classified_listing_tag_names<'a>(
+/// Partitions borrowed raw tag names without allocating or validating tag arity.
+pub fn classify_classified_listing_marker_names<'a>(
names: impl IntoIterator<Item = Option<&'a str>>,
) -> RadrootsClassifiedListingPartition {
let mut has_focused_marker = false;
diff --git a/crates/event/src/order.rs b/crates/event/src/order.rs
@@ -1584,6 +1584,10 @@ mod tests {
"invalid listing kind: 0"
);
assert_eq!(
+ RadrootsOperationalListingValidationError::InvalidProfile.to_string(),
+ "classified listing is not an Operational Listing profile"
+ );
+ assert_eq!(
RadrootsOperationalListingValidationError::MissingListingId.to_string(),
"missing listing id"
);
diff --git a/crates/event/src/trade_validation.rs b/crates/event/src/trade_validation.rs
@@ -20,6 +20,7 @@ pub enum RadrootsOperationalListingValidationError {
InvalidKind {
kind: u32,
},
+ InvalidProfile,
MissingListingId,
ListingEventNotFound {
listing_addr: String,
@@ -55,6 +56,12 @@ impl core::fmt::Display for RadrootsOperationalListingValidationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::InvalidKind { kind } => write!(f, "invalid listing kind: {kind}"),
+ Self::InvalidProfile => {
+ write!(
+ f,
+ "classified listing is not an Operational Listing profile"
+ )
+ }
Self::MissingListingId => write!(f, "missing listing id"),
Self::ListingEventNotFound { listing_addr } => {
write!(f, "listing event not found: {listing_addr}")
@@ -96,6 +103,10 @@ mod tests {
#[test]
fn listing_validation_error_display_covers_location_variants() {
assert_eq!(
+ RadrootsOperationalListingValidationError::InvalidProfile.to_string(),
+ "classified listing is not an Operational Listing profile"
+ );
+ assert_eq!(
RadrootsOperationalListingValidationError::MissingLocation.to_string(),
"missing listing location"
);
diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml
@@ -17,13 +17,9 @@ default = ["std"]
std = []
blossom = ["std", "dep:base64", "dep:radroots_blossom"]
client = ["std", "dep:nostr-sdk", "dep:radroots_identity"]
-codec = [
- "dep:radroots_event",
- "dep:radroots_event_codec",
- "radroots_event_codec/nostr",
-]
+codec = ["dep:radroots_event_codec", "radroots_event_codec/nostr"]
events = [
- "dep:radroots_event",
+ "std",
"dep:radroots_event_codec",
"radroots_event/std",
"radroots_event/serde",
@@ -35,7 +31,7 @@ nip17 = ["std", "codec", "nostr/nip44", "nostr/nip59"]
[dependencies]
base64 = { workspace = true, optional = true }
radroots_blossom = { workspace = true, optional = true, default-features = false }
-radroots_event = { workspace = true, optional = true, default-features = false }
+radroots_event = { workspace = true, default-features = false }
radroots_event_codec = { workspace = true, optional = true, default-features = false }
radroots_identity = { workspace = true, optional = true, default-features = false, features = [
"std",
@@ -54,8 +50,13 @@ thiserror = { workspace = true }
radroots_blossom = { workspace = true, default-features = false, features = [
"std",
] }
+radroots_test_fixtures = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
[[test]]
name = "post_profile"
required-features = ["events"]
+
+[[test]]
+name = "food_availability_profile"
+required-features = ["events"]
diff --git a/crates/nostr/README b/crates/nostr/README
@@ -19,18 +19,27 @@ verifies their `Authorization: Nostr` HTTP values. It does not publish these
ephemeral authorization events to relays. Pure BUD-11 claim parsing and policy
validation remain in `radroots_blossom`.
-With the `events` feature, kind-1 root publication is available only through
-typed Update, PhotoUpdate, and Ask builders backed by the strict
-`radroots_event_codec` wire operations. The former free-form text-note post
-builder is removed. Reply construction remains a separate compatibility
-surface pending the dedicated strict NIP-10 contract; it is not used to author
-root product cards. Generic protocol builders reject kind-0 Profile events and
-unmarked root kind-1 events at both direct signing and client-publication
-boundaries. Kind-1 builders carrying an `e` tag remain available for thread
-compatibility. Typed media builders can sign or publish only after the owning
-runtime separately proves successful BUD-02 upload completion; their
-byte-verified descriptors do not attest upload completion. The generic net
-manager intentionally exposes no direct PhotoUpdate or media Ask publisher.
+The `events` feature is std-backed. With it, kind-1 root publication is
+available only through typed Update, PhotoUpdate, and Ask builders backed by
+the strict `radroots_event_codec` wire operations. The former free-form
+text-note post builder is removed. Reply construction remains a separate
+compatibility surface pending the dedicated strict NIP-10 contract; it is not
+used to author root product cards. Generic protocol builders reject kind-0
+Profile events and unmarked root kind-1 events at both direct signing and
+client-publication boundaries. Kind-1 builders carrying an `e` tag remain
+available for thread compatibility. Typed media builders can sign or publish
+only after the owning runtime separately proves successful BUD-02 upload
+completion; their byte-verified descriptors do not attest upload completion.
+The generic net manager intentionally exposes no direct PhotoUpdate or media
+Ask publisher.
+
+Focused FoodAvailability kind-30402 authoring likewise uses a sealed builder.
+Its `created_at` is fixed during strict construction and cannot be changed
+after wire validation. Generic direct signing and client publication reject
+focused or mixed FoodAvailability/Operational Listing markers before signer
+access; marker-free NIP-99 and operational-only compatibility builders remain
+available. Relaying an already signed event remains a transport operation and
+does not establish typed FoodAvailability authoring.
The opaque generic-builder policy governs Radroots builder signing and client
publication. It does not redefine the standard NIP-46 `sign_event` method or a
diff --git a/crates/nostr/src/client.rs b/crates/nostr/src/client.rs
@@ -10,6 +10,8 @@ use radroots_identity::RadrootsIdentity;
use crate::error::RadrootsNostrError;
#[cfg(feature = "events")]
+use crate::events::food_availability::RadrootsNostrFoodAvailabilityEventBuilder;
+#[cfg(feature = "events")]
use crate::events::post::RadrootsNostrPostEventBuilder;
use crate::types::{
RadrootsNostrEvent, RadrootsNostrEventId, RadrootsNostrEventStream, RadrootsNostrFilter,
@@ -243,9 +245,11 @@ impl RadrootsNostrClient {
/// Publishes a generic event builder.
///
- /// Kind 0 profiles and unmarked root kind 1 events are rejected because
- /// their product shape must come from typed authoring. Kind 1 builders
- /// with an `e` tag remain available for thread compatibility.
+ /// Kind 0 profiles, unmarked root kind 1 events, and focused or mixed kind
+ /// 30402 FoodAvailability marker partitions are rejected because their
+ /// product shape must come from typed authoring. Thread kind 1, marker-free
+ /// NIP-99, and operational-only kind 30402 builders remain available for
+ /// compatibility.
pub async fn send_event_builder(
&self,
event: RadrootsNostrGenericEventBuilder,
@@ -266,6 +270,20 @@ impl RadrootsNostrClient {
.await?)
}
+ /// Publishes a validated focused FoodAvailability event.
+ ///
+ /// Media-bearing callers must prove successful BUD-02 upload first.
+ #[cfg(feature = "events")]
+ pub async fn send_food_availability_event_builder(
+ &self,
+ event: RadrootsNostrFoodAvailabilityEventBuilder,
+ ) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> {
+ Ok(self
+ .inner
+ .send_event_builder(event.into_event_builder())
+ .await?)
+ }
+
/// Relays a caller-supplied signed event.
///
/// This is a transport boundary, not an authored-builder boundary. The
@@ -314,6 +332,17 @@ pub async fn radroots_nostr_send_post_event(
client.send_post_event_builder(event).await
}
+/// Publishes a validated focused FoodAvailability event.
+///
+/// Media-bearing callers must prove successful BUD-02 upload first.
+#[cfg(feature = "events")]
+pub async fn radroots_nostr_send_food_availability_event(
+ client: &RadrootsNostrClient,
+ event: RadrootsNostrFoodAvailabilityEventBuilder,
+) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> {
+ client.send_food_availability_event_builder(event).await
+}
+
pub async fn radroots_nostr_fetch_event_by_id(
client: &RadrootsNostrClient,
id: &str,
@@ -378,10 +407,11 @@ mod tests {
}
#[tokio::test]
- async fn generic_builder_rejects_typed_only_profiles_and_root_kind_one() {
+ async fn generic_builder_rejects_all_typed_authoring_reservations_before_signer_access() {
let client = RadrootsNostrClient::new_signerless();
let raw_kind_one = RadrootsNostrKind::Custom(RadrootsNostrKind::TextNote.as_u16());
- let builders = [
+ let classified_listing = RadrootsNostrKind::Custom(30_402);
+ let builders = vec![
RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Metadata, "{}"),
RadrootsNostrGenericEventBuilder::new(raw_kind_one, "Unmarked root"),
RadrootsNostrGenericEventBuilder::new(raw_kind_one, "Is it ripe?").tags([
@@ -393,6 +423,14 @@ mod tests {
])
.expect("image metadata"),
]),
+ RadrootsNostrGenericEventBuilder::new(classified_listing, "Focused").tag(
+ RadrootsNostrTag::parse(["radroots:price_unit", "lb"]).expect("focused marker"),
+ ),
+ RadrootsNostrGenericEventBuilder::new(classified_listing, "Ambiguous").tags([
+ RadrootsNostrTag::parse(["radroots:price_unit", "lb"]).expect("focused marker"),
+ RadrootsNostrTag::parse(["radroots:primary_bin", "bin-1"])
+ .expect("operational marker"),
+ ]),
];
for builder in builders {
diff --git a/crates/nostr/src/error.rs b/crates/nostr/src/error.rs
@@ -38,6 +38,13 @@ pub enum RadrootsNostrError {
EventWire(#[from] radroots_event::wire::RadrootsEventWireError),
#[cfg(feature = "events")]
+ #[error("FoodAvailability encoding error: {0}")]
+ FoodAvailabilityEncode(
+ #[from]
+ radroots_event_codec::food_availability::authored::RadrootsFoodAvailabilityEncodeError,
+ ),
+
+ #[cfg(feature = "events")]
#[error("Signed event error: {0}")]
SignedEvent(#[from] radroots_event::draft::RadrootsSignedEventError),
diff --git a/crates/nostr/src/events/food_availability.rs b/crates/nostr/src/events/food_availability.rs
@@ -0,0 +1,68 @@
+use crate::{
+ error::RadrootsNostrError,
+ types::{
+ RadrootsNostrEvent, RadrootsNostrEventBuilderUnchecked, RadrootsNostrKeys,
+ RadrootsNostrTimestamp,
+ },
+};
+use radroots_event::food_availability::RadrootsFoodAvailabilityDetails;
+use radroots_event_codec::food_availability::authored::authored_food_availability_to_wire_parts;
+
+/// A sealed builder for a validated focused FoodAvailability event.
+///
+/// The timestamp is fixed during typed construction because it participates in
+/// strict domain validation and the canonical compact-wire budget.
+/// Byte-verified image descriptors are not upload evidence. A media-bearing
+/// caller must prove successful BUD-02 upload before signing or publication.
+///
+/// ```compile_fail
+/// use radroots_nostr::prelude::{
+/// RadrootsNostrFoodAvailabilityEventBuilder, RadrootsNostrTimestamp,
+/// };
+///
+/// fn replace_validated_timestamp(builder: RadrootsNostrFoodAvailabilityEventBuilder) {
+/// let _ = builder.custom_created_at(RadrootsNostrTimestamp::from_secs(1));
+/// }
+/// ```
+///
+/// ```compile_fail
+/// use radroots_nostr::prelude::RadrootsNostrFoodAvailabilityEventBuilder;
+///
+/// fn expose_raw_builder(builder: RadrootsNostrFoodAvailabilityEventBuilder) {
+/// let _: nostr::EventBuilder = builder.into();
+/// }
+/// ```
+#[must_use = "FoodAvailability event builders must be signed or published"]
+pub struct RadrootsNostrFoodAvailabilityEventBuilder {
+ inner: RadrootsNostrEventBuilderUnchecked,
+}
+
+impl RadrootsNostrFoodAvailabilityEventBuilder {
+ /// Signs the validated event directly with local keys.
+ ///
+ /// Media-bearing callers must prove successful BUD-02 upload first.
+ pub fn sign_with_keys(
+ self,
+ keys: &RadrootsNostrKeys,
+ ) -> Result<RadrootsNostrEvent, RadrootsNostrError> {
+ Ok(self.inner.sign_with_keys(keys)?)
+ }
+
+ #[cfg(feature = "client")]
+ pub(crate) fn into_event_builder(self) -> RadrootsNostrEventBuilderUnchecked {
+ self.inner
+ }
+}
+
+/// Builds a sealed Nostr builder from strict FoodAvailability details.
+///
+/// This validates media descriptors but does not attest BUD-02 upload.
+pub fn radroots_nostr_build_food_availability_event(
+ details: &RadrootsFoodAvailabilityDetails,
+ created_at: RadrootsNostrTimestamp,
+) -> Result<RadrootsNostrFoodAvailabilityEventBuilder, RadrootsNostrError> {
+ let parts = authored_food_availability_to_wire_parts(details, created_at.as_secs())?;
+ let inner = super::radroots_nostr_build_event_unchecked(parts.kind, parts.content, parts.tags)?
+ .custom_created_at(created_at);
+ Ok(RadrootsNostrFoodAvailabilityEventBuilder { inner })
+}
diff --git a/crates/nostr/src/events/mod.rs b/crates/nostr/src/events/mod.rs
@@ -1,5 +1,7 @@
#[cfg(feature = "events")]
pub mod application_handler;
+#[cfg(feature = "events")]
+pub mod food_availability;
pub mod jobs;
pub mod metadata;
pub mod post;
diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs
@@ -61,7 +61,9 @@ pub mod prelude {
};
#[cfg(all(feature = "client", feature = "events"))]
- pub use crate::client::radroots_nostr_send_post_event;
+ pub use crate::client::{
+ radroots_nostr_send_food_availability_event, radroots_nostr_send_post_event,
+ };
pub use crate::error::{RadrootsNostrError, RadrootsNostrTagsResolveError};
pub use crate::filter::{
@@ -75,6 +77,11 @@ pub mod prelude {
};
#[cfg(feature = "events")]
+ pub use crate::events::food_availability::{
+ RadrootsNostrFoodAvailabilityEventBuilder, radroots_nostr_build_food_availability_event,
+ };
+
+ #[cfg(feature = "events")]
pub use crate::events::post::{
RadrootsNostrPostEventBuilder, radroots_nostr_build_ask_event,
radroots_nostr_build_photo_update_event, radroots_nostr_build_update_event,
diff --git a/crates/nostr/src/types.rs b/crates/nostr/src/types.rs
@@ -1,6 +1,9 @@
#![forbid(unsafe_code)]
use crate::error::RadrootsNostrError;
+use radroots_event::classified_listing::{
+ RadrootsClassifiedListingPartition, classify_classified_listing_marker_names,
+};
pub type RadrootsNostrCoordinate = nostr::nips::nip01::Coordinate;
pub type RadrootsNostrEvent = nostr::Event;
@@ -22,10 +25,12 @@ pub type RadrootsNostrUrl = nostr::Url;
/// An opaque builder for generic Nostr events.
///
-/// Kind 0 profile events and unmarked root kind 1 events are reserved for
-/// typed Radroots authoring. Kind 1 events carrying an `e` tag remain
-/// available for thread compatibility. The policy is enforced before direct
-/// signing and before a client is allowed to consult its signer.
+/// Kind 0 profile events, unmarked root kind 1 events, and focused or mixed
+/// kind 30402 FoodAvailability marker partitions are reserved for typed
+/// Radroots authoring. Kind 1 events carrying an `e` tag, marker-free NIP-99,
+/// and operational-only kind 30402 events remain available for compatibility.
+/// The policy is enforced before direct signing and before a client is allowed
+/// to consult its signer.
///
/// The upstream unsigned builder is intentionally inaccessible:
///
@@ -153,7 +158,23 @@ impl RadrootsNostrGenericEventBuilder {
.tags
.iter()
.any(|tag| tag.kind() == RadrootsNostrTagKind::e());
- if is_profile || is_unmarked_root_post {
+ let classified_listing_partition =
+ (kind == radroots_event::kinds::KIND_CLASSIFIED_LISTING as u16).then(|| {
+ classify_classified_listing_marker_names(
+ event
+ .tags
+ .iter()
+ .map(|tag| tag.as_slice().first().map(|name| name.as_str())),
+ )
+ });
+ let is_reserved_focused_listing = matches!(
+ classified_listing_partition,
+ Some(
+ RadrootsClassifiedListingPartition::FocusedFoodAvailability
+ | RadrootsClassifiedListingPartition::Ambiguous
+ )
+ );
+ if is_profile || is_unmarked_root_post || is_reserved_focused_listing {
return Err(RadrootsNostrError::TypedAuthoringRequired { kind });
}
Ok(())
diff --git a/crates/nostr/tests/food_availability_profile.rs b/crates/nostr/tests/food_availability_profile.rs
@@ -0,0 +1,235 @@
+use radroots_blossom::{
+ RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType,
+ RadrootsBlossomSha256,
+};
+use radroots_event::food_availability::{
+ RadrootsFoodAvailabilityDetails, RadrootsFoodAvailabilityDetailsParts,
+ RadrootsFoodAvailabilityImage, RadrootsFoodAvailabilityStatus, RadrootsFoodContent,
+ RadrootsFoodCurrency, RadrootsFoodIdentifier, RadrootsFoodImageDimensions, RadrootsFoodPrice,
+ RadrootsFoodPublishedAt, RadrootsFoodQuantity, RadrootsFoodText, RadrootsFoodUnit,
+};
+use radroots_event::media::RadrootsAuthoredImage;
+use radroots_event_codec::food_availability::admission::{
+ RadrootsFoodAvailabilityAdmissionOutcome, verify_and_admit_food_availability_event,
+};
+use radroots_nostr::prelude::{
+ RadrootsNostrError, RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, RadrootsNostrKind,
+ RadrootsNostrSecretKey, RadrootsNostrTag, RadrootsNostrTimestamp, radroots_event_from_nostr,
+ radroots_nostr_build_food_availability_event,
+};
+use radroots_test_fixtures::FIXTURE_ALICE_SECRET_KEY_HEX;
+
+#[cfg(feature = "client")]
+use radroots_nostr::prelude::RadrootsNostrClient;
+
+const CREATED_AT: u64 = 1_784_347_200;
+
+#[test]
+fn typed_food_builder_signs_the_exact_strict_profile() {
+ let keys = fixture_keys();
+ let created_at = RadrootsNostrTimestamp::from_secs(CREATED_AT);
+ let event = radroots_nostr_build_food_availability_event(&details(), created_at)
+ .expect("typed FoodAvailability builder")
+ .sign_with_keys(&keys)
+ .expect("signed FoodAvailability event");
+
+ assert_eq!(
+ event.kind,
+ RadrootsNostrKind::Custom(
+ u16::try_from(radroots_event::kinds::KIND_CLASSIFIED_LISTING)
+ .expect("classified listing kind")
+ )
+ );
+ assert_eq!(event.created_at, created_at);
+ assert_eq!(
+ event
+ .tags
+ .iter()
+ .map(|tag| tag.as_slice().to_vec())
+ .collect::<Vec<_>>(),
+ vec![
+ vec!["d", "nantes-carrots"],
+ vec!["title", "Nantes Carrots"],
+ vec!["summary", "Fresh bunches"],
+ vec!["published_at", "1784347100"],
+ vec!["location", "Central Saanich, BC"],
+ vec!["price", "3", "CAD"],
+ vec!["radroots:price_unit", "lb"],
+ vec!["radroots:quantity", "24", "lb"],
+ vec!["status", "active"],
+ ]
+ );
+ event.verify().expect("valid NIP-01 event");
+
+ let envelope = radroots_event_from_nostr(&event).expect("Radroots event adapter");
+ assert!(matches!(
+ verify_and_admit_food_availability_event(envelope)
+ .expect("verified FoodAvailability admission"),
+ RadrootsFoodAvailabilityAdmissionOutcome::Admitted(_)
+ ));
+}
+
+#[test]
+fn typed_food_builder_keeps_timestamp_validation_inside_construction() {
+ let error = radroots_nostr_build_food_availability_event(
+ &details(),
+ RadrootsNostrTimestamp::from_secs(1_784_347_000),
+ )
+ .err()
+ .expect("created_at before published_at must fail");
+
+ assert!(matches!(
+ error,
+ RadrootsNostrError::FoodAvailabilityEncode(_)
+ ));
+}
+
+#[test]
+fn typed_food_builder_preserves_a_byte_verified_blossom_image_tuple() {
+ let image = blossom_image();
+ let image_url = image.url().to_owned();
+ let event = radroots_nostr_build_food_availability_event(
+ &details_with_images(vec![image]),
+ RadrootsNostrTimestamp::from_secs(CREATED_AT),
+ )
+ .expect("typed media FoodAvailability builder")
+ .sign_with_keys(&fixture_keys())
+ .expect("signed media FoodAvailability event");
+
+ let image_tags = event
+ .tags
+ .iter()
+ .filter(|tag| tag.as_slice().first().is_some_and(|name| name == "image"))
+ .map(|tag| tag.as_slice().to_vec())
+ .collect::<Vec<_>>();
+ assert_eq!(
+ image_tags,
+ vec![vec!["image".to_owned(), image_url, "640x480".to_owned(),]]
+ );
+}
+
+#[test]
+fn generic_builder_reserves_focused_and_ambiguous_listing_profiles() {
+ let keys = fixture_keys();
+ let kind = RadrootsNostrKind::Custom(
+ u16::try_from(radroots_event::kinds::KIND_CLASSIFIED_LISTING)
+ .expect("classified listing kind"),
+ );
+
+ for tags in [
+ vec![
+ RadrootsNostrTag::parse(["d", "focused"]).expect("d tag"),
+ RadrootsNostrTag::parse(["radroots:price_unit", "lb"]).expect("focused marker"),
+ ],
+ vec![
+ RadrootsNostrTag::parse(["d", "ambiguous"]).expect("d tag"),
+ RadrootsNostrTag::parse(["radroots:price_unit", "lb"]).expect("focused marker"),
+ RadrootsNostrTag::parse(["radroots:primary_bin", "bin-1"]).expect("operational marker"),
+ ],
+ ] {
+ assert!(matches!(
+ RadrootsNostrGenericEventBuilder::new(kind, "reserved")
+ .tags(tags)
+ .sign_with_keys(&keys),
+ Err(RadrootsNostrError::TypedAuthoringRequired { kind: 30_402 })
+ ));
+ }
+}
+
+#[test]
+fn generic_builder_retains_marker_free_and_operational_nip99_compatibility() {
+ let keys = fixture_keys();
+ let kind = RadrootsNostrKind::Custom(
+ u16::try_from(radroots_event::kinds::KIND_CLASSIFIED_LISTING)
+ .expect("classified listing kind"),
+ );
+
+ for tags in [
+ vec![RadrootsNostrTag::parse(["d", "generic"]).expect("d tag")],
+ vec![
+ RadrootsNostrTag::parse(["d", "operational"]).expect("d tag"),
+ RadrootsNostrTag::parse(["radroots:primary_bin", "bin-1"]).expect("operational marker"),
+ ],
+ ] {
+ let event = RadrootsNostrGenericEventBuilder::new(kind, "compatible")
+ .tags(tags)
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(CREATED_AT))
+ .sign_with_keys(&keys)
+ .expect("non-focused NIP-99 remains generic-authorable");
+ event.verify().expect("valid generic NIP-99 event");
+ }
+}
+
+#[cfg(feature = "client")]
+#[tokio::test]
+async fn typed_food_builder_reaches_client_publication() {
+ let client = RadrootsNostrClient::new(fixture_keys());
+ let builder = radroots_nostr_build_food_availability_event(
+ &details(),
+ RadrootsNostrTimestamp::from_secs(CREATED_AT),
+ )
+ .expect("typed FoodAvailability builder");
+
+ let error = client
+ .send_food_availability_event_builder(builder)
+ .await
+ .expect_err("no relay is configured");
+
+ assert!(matches!(error, RadrootsNostrError::ClientError(_)));
+}
+
+fn fixture_keys() -> RadrootsNostrKeys {
+ RadrootsNostrKeys::new(
+ RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("fixture secret key"),
+ )
+}
+
+fn details() -> RadrootsFoodAvailabilityDetails {
+ details_with_images(Vec::new())
+}
+
+fn details_with_images(
+ images: Vec<RadrootsFoodAvailabilityImage>,
+) -> RadrootsFoodAvailabilityDetails {
+ RadrootsFoodAvailabilityDetails::new(RadrootsFoodAvailabilityDetailsParts {
+ content: RadrootsFoodContent::new("Carrots available this week.").expect("content"),
+ identifier: RadrootsFoodIdentifier::parse("nantes-carrots").expect("identifier"),
+ title: RadrootsFoodText::new("Nantes Carrots").expect("title"),
+ summary: RadrootsFoodText::new("Fresh bunches").expect("summary"),
+ published_at: RadrootsFoodPublishedAt::new(1_784_347_100).expect("published_at"),
+ location: RadrootsFoodText::new("Central Saanich, BC").expect("location"),
+ price: RadrootsFoodPrice::new(
+ "3",
+ RadrootsFoodCurrency::parse("CAD").expect("currency"),
+ RadrootsFoodUnit::Pound,
+ )
+ .expect("price"),
+ quantity: Some(RadrootsFoodQuantity::new("24", RadrootsFoodUnit::Pound).expect("quantity")),
+ status: RadrootsFoodAvailabilityStatus::Active,
+ images,
+ })
+ .expect("FoodAvailability details")
+}
+
+fn blossom_image() -> RadrootsFoodAvailabilityImage {
+ let bytes = b"victoria-carrots-image-fixture";
+ let hash = RadrootsBlossomSha256::digest(bytes);
+ let media_type = RadrootsBlossomMediaType::parse("image/webp").expect("image media type");
+ let verified = RadrootsBlossomBlobDescriptor::new(
+ RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.webp"))
+ .expect("Blossom URL"),
+ hash,
+ bytes.len() as u64,
+ media_type.clone(),
+ CREATED_AT,
+ )
+ .expect("Blossom descriptor")
+ .approve_reference()
+ .expect("approved Blossom reference")
+ .verify_bytes(bytes, &media_type)
+ .expect("byte-verified Blossom descriptor");
+ let image =
+ RadrootsAuthoredImage::try_from_verified_descriptor(verified).expect("authored image");
+ let dimensions = RadrootsFoodImageDimensions::new(640, 480).expect("image dimensions");
+ RadrootsFoodAvailabilityImage::new(image, dimensions)
+}
diff --git a/crates/replica_sync/Cargo.toml b/crates/replica_sync/Cargo.toml
@@ -20,6 +20,7 @@ default = ["std"]
std = [
"radroots_event/std",
"radroots_event_codec/std",
+ "radroots_event_codec/nostr",
"radroots_sql_core/std",
"dep:base64",
"dep:uuid",
@@ -49,6 +50,12 @@ base64 = { workspace = true, optional = true }
uuid = { workspace = true, optional = true }
[dev-dependencies]
+nostr = { workspace = true }
+radroots_nostr = { workspace = true, default-features = false, features = [
+ "events",
+ "std",
+] }
+radroots_test_fixtures = { workspace = true }
radroots_sql_core = { workspace = true, features = ["native"] }
radroots_replica_store = { workspace = true }
serde_json = { workspace = true }
diff --git a/crates/replica_sync/src/error.rs b/crates/replica_sync/src/error.rs
@@ -4,6 +4,7 @@ use alloc::string::{String, ToString};
use core::fmt;
use radroots_event_codec::error::{EventEncodeError, EventParseError};
+use radroots_event_codec::verification::RadrootsNip01VerificationError;
use radroots_replica_schema::ReplicaSchemaError;
use radroots_sql_core::error::SqlError;
@@ -12,6 +13,7 @@ pub enum RadrootsReplicaEventsError {
Sql(ReplicaSchemaError<SqlError>),
Encode(EventEncodeError),
Parse(EventParseError),
+ Verification(RadrootsNip01VerificationError),
InvalidSelector(String),
InvalidData(String),
}
@@ -22,6 +24,7 @@ impl fmt::Display for RadrootsReplicaEventsError {
Self::Sql(err) => write!(f, "replica_sync.sql: {}", err.error),
Self::Encode(err) => write!(f, "replica_sync.encode: {err}"),
Self::Parse(err) => write!(f, "replica_sync.parse: {err}"),
+ Self::Verification(err) => write!(f, "replica_sync.verification: {err}"),
Self::InvalidSelector(msg) => write!(f, "replica_sync.selector: {msg}"),
Self::InvalidData(msg) => write!(f, "replica_sync.data: {msg}"),
}
@@ -49,10 +52,17 @@ impl From<EventParseError> for RadrootsReplicaEventsError {
}
}
+impl From<RadrootsNip01VerificationError> for RadrootsReplicaEventsError {
+ fn from(err: RadrootsNip01VerificationError) -> Self {
+ Self::Verification(err)
+ }
+}
+
#[cfg(test)]
mod tests {
use super::RadrootsReplicaEventsError;
use radroots_event_codec::error::{EventEncodeError, EventParseError};
+ use radroots_event_codec::verification::RadrootsNip01VerificationError;
use radroots_replica_schema::ReplicaSchemaError;
use radroots_sql_core::error::SqlError;
@@ -67,6 +77,15 @@ mod tests {
let parse_err = RadrootsReplicaEventsError::Parse(EventParseError::InvalidTag("d"));
assert!(parse_err.to_string().contains("replica_sync.parse"));
+ let verification_err = RadrootsReplicaEventsError::Verification(
+ RadrootsNip01VerificationError::SignatureInvalid,
+ );
+ assert!(
+ verification_err
+ .to_string()
+ .contains("replica_sync.verification")
+ );
+
let selector_err =
RadrootsReplicaEventsError::InvalidSelector("selector missing".to_string());
assert!(selector_err.to_string().contains("replica_sync.selector"));
@@ -88,5 +107,13 @@ mod tests {
let parse_from: RadrootsReplicaEventsError =
EventParseError::InvalidNumber("k", parse_number_err).into();
assert!(parse_from.to_string().contains("replica_sync.parse"));
+
+ let verification_from: RadrootsReplicaEventsError =
+ RadrootsNip01VerificationError::SignatureInvalid.into();
+ assert!(
+ verification_from
+ .to_string()
+ .contains("replica_sync.verification")
+ );
}
}
diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs
@@ -12,13 +12,13 @@ use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use radroots_core::RadrootsCoreDecimal;
-use radroots_event::RadrootsEventEnvelope;
#[cfg(test)]
use radroots_event::RadrootsEventEnvelopeParts;
+use radroots_event::contract::RadrootsEventClass;
use radroots_event::event_head::{
RadrootsCurrentEventHead, RadrootsEventHeadCandidateResult, RadrootsEventHeadCoordinate,
- RadrootsEventHeadDecision as ProtocolEventHeadDecision, event_head_candidate_for_event,
- select_event_head,
+ RadrootsEventHeadDecision as ProtocolEventHeadDecision, event_head_candidate_for_class,
+ event_head_candidate_for_event, select_event_head,
};
use radroots_event::ids::RadrootsEventId;
use radroots_event::kinds::{
@@ -29,11 +29,19 @@ use radroots_event::operational_listing::{
RadrootsOperationalListing, RadrootsOperationalListingAvailability,
RadrootsOperationalListingBin, RadrootsOperationalListingStatus,
};
+use radroots_event::{
+ RadrootsEventEnvelope,
+ classified_listing::{RadrootsClassifiedListingPartition, classify_classified_listing_tags},
+};
use radroots_event_codec::farm::decode as farm_decode;
+use radroots_event_codec::food_availability::inbound::{
+ RadrootsFoodAvailabilityProjectionOutcome, project_verified_food_availability_event,
+};
use radroots_event_codec::list_set::decode as list_set_decode;
use radroots_event_codec::operational_listing::decode as listing_decode;
use radroots_event_codec::plot::decode as plot_decode;
use radroots_event_codec::profile::decode as profile_decode;
+use radroots_event_codec::verification::{RadrootsSignatureVerifiedEvent, verify_nip01_event};
use radroots_replica_schema::ReplicaSchemaError;
use radroots_replica_schema::farm::{
FarmQueryBindValues, IFarmFields, IFarmFieldsFilter, IFarmFieldsPartial, IFarmFindMany,
@@ -136,7 +144,13 @@ pub(crate) mod failpoints {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadrootsReplicaIngestOutcome {
+ /// The selected event updated its supported legacy projection and raw head.
Applied,
+ /// The selected raw head belongs to a valid profile this legacy projection excludes.
+ Excluded,
+ /// The selected raw head is invalid or ambiguous for its declared profile.
+ Rejected,
+ /// The event did not win NIP-01 replacement ordering.
Skipped,
}
@@ -177,13 +191,19 @@ pub fn radroots_replica_ingest_event_with_factory(
event: &RadrootsEventEnvelope,
factory: &dyn RadrootsReplicaIdFactory,
) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> {
+ let verified_classified_listing = if event.kind_u32() == KIND_CLASSIFIED_LISTING {
+ Some(verify_nip01_event(event.clone())?)
+ } else {
+ None
+ };
+
if let Err(err) = exec.begin() {
return Err(RadrootsReplicaEventsError::from(ReplicaSchemaError::from(
err,
)));
}
- match ingest_event_inner(exec, event, factory) {
+ match ingest_event_inner(exec, event, factory, verified_classified_listing.as_ref()) {
Ok(outcome) => {
if let Err(err) = exec.commit() {
return Err(RadrootsReplicaEventsError::from(ReplicaSchemaError::from(
@@ -203,12 +223,20 @@ fn ingest_event_inner(
exec: &dyn SqlExecutor,
event: &RadrootsEventEnvelope,
factory: &dyn RadrootsReplicaIdFactory,
+ verified_classified_listing: Option<&RadrootsSignatureVerifiedEvent>,
) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> {
match event.kind_u32() {
KIND_PROFILE => ingest_profile_event(exec, event),
KIND_FARM => ingest_farm_event(exec, event, factory),
KIND_PLOT => ingest_plot_event(exec, event, factory),
- KIND_CLASSIFIED_LISTING => ingest_listing_event(exec, event),
+ KIND_CLASSIFIED_LISTING => {
+ let verified_event = verified_classified_listing.ok_or_else(|| {
+ RadrootsReplicaEventsError::InvalidData(
+ "classified listing verification invariant missing".to_string(),
+ )
+ })?;
+ ingest_listing_event(exec, verified_event)
+ }
kind if is_nip51_list_set_kind(kind) && kind != KIND_CALENDAR => {
ingest_list_set_event(exec, event)
}
@@ -473,30 +501,92 @@ fn ingest_plot_event(
fn ingest_listing_event(
exec: &dyn SqlExecutor,
- event: &RadrootsEventEnvelope,
+ verified_event: &RadrootsSignatureVerifiedEvent,
) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> {
- let listing = listing_decode::operational_listing_from_event(
- event.kind_u32(),
- &event.tags_as_vec(),
- event.content(),
- )?;
+ let event = verified_event.event();
let decision = event_head_decision(exec, event)?;
if !decision.apply {
return Ok(RadrootsReplicaIngestOutcome::Skipped);
}
- let listing_addr = listing_event_addr(event, &listing);
+ let partition = classify_classified_listing_tags(event.tags());
+ if partition == RadrootsClassifiedListingPartition::FocusedFoodAvailability {
+ let outcome = match project_verified_food_availability_event(verified_event) {
+ Ok(RadrootsFoodAvailabilityProjectionOutcome::Focused(_)) => {
+ RadrootsReplicaIngestOutcome::Excluded
+ }
+ Ok(RadrootsFoodAvailabilityProjectionOutcome::Excluded(_)) | Err(_) => {
+ RadrootsReplicaIngestOutcome::Rejected
+ }
+ Ok(_) => RadrootsReplicaIngestOutcome::Rejected,
+ };
+ return replace_listing_projection_with_raw_head(exec, &decision, outcome);
+ }
+ if partition == RadrootsClassifiedListingPartition::GenericNip99 {
+ return replace_listing_projection_with_raw_head(
+ exec,
+ &decision,
+ RadrootsReplicaIngestOutcome::Excluded,
+ );
+ }
+ if partition == RadrootsClassifiedListingPartition::Ambiguous {
+ return replace_listing_projection_with_raw_head(
+ exec,
+ &decision,
+ RadrootsReplicaIngestOutcome::Rejected,
+ );
+ }
+
+ let listing = match listing_decode::operational_listing_from_event(
+ event.kind_u32(),
+ &event.tags_as_vec(),
+ event.content(),
+ ) {
+ Ok(listing) => listing,
+ Err(_) => {
+ return replace_listing_projection_with_raw_head(
+ exec,
+ &decision,
+ RadrootsReplicaIngestOutcome::Rejected,
+ );
+ }
+ };
+
+ let listing_addr = decision.key.as_str();
if listing_is_orderable(&listing) {
- let fields = trade_product_fields_from_listing(&listing, &listing_addr)?;
- upsert_trade_product_for_listing_addr(exec, &listing_addr, fields)?;
+ let fields = match trade_product_fields_from_listing(&listing, listing_addr) {
+ Ok(fields) => fields,
+ Err(_) => {
+ return replace_listing_projection_with_raw_head(
+ exec,
+ &decision,
+ RadrootsReplicaIngestOutcome::Rejected,
+ );
+ }
+ };
+ upsert_trade_product_for_listing_addr(exec, listing_addr, fields)?;
} else {
- delete_trade_products_for_listing_addr(exec, &listing_addr)?;
+ delete_trade_products_for_listing_addr(exec, listing_addr)?;
}
upsert_event_head(exec, &decision)?;
Ok(RadrootsReplicaIngestOutcome::Applied)
}
+fn replace_listing_projection_with_raw_head(
+ exec: &dyn SqlExecutor,
+ decision: &EventHeadDecision,
+ outcome: RadrootsReplicaIngestOutcome,
+) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> {
+ debug_assert!(matches!(
+ outcome,
+ RadrootsReplicaIngestOutcome::Excluded | RadrootsReplicaIngestOutcome::Rejected
+ ));
+ delete_trade_products_for_listing_addr(exec, &decision.key)?;
+ upsert_event_head(exec, decision)?;
+ Ok(outcome)
+}
+
fn ingest_list_set_event(
exec: &dyn SqlExecutor,
event: &RadrootsEventEnvelope,
@@ -563,18 +653,6 @@ fn ingest_list_set_event(
))
}
-fn listing_event_addr(
- event: &RadrootsEventEnvelope,
- listing: &RadrootsOperationalListing,
-) -> String {
- format!(
- "{}:{}:{}",
- event.kind_u32(),
- event.author_str(),
- listing.d_tag
- )
-}
-
fn listing_is_orderable(listing: &RadrootsOperationalListing) -> bool {
match listing.availability.as_ref() {
Some(RadrootsOperationalListingAvailability::Status { status }) => {
@@ -849,10 +927,20 @@ fn trade_product_partial_from_fields(fields: &ITradeProductFields) -> ITradeProd
}
}
+/// Advances a supported non-classified event head without running projection.
+///
+/// Kind 30402 is rejected because its raw head and profile-aware projection
+/// cleanup must be applied atomically through full replica ingestion.
pub fn radroots_replica_ingest_event_head(
exec: &dyn SqlExecutor,
event: &RadrootsEventEnvelope,
) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> {
+ if event.kind_u32() == KIND_CLASSIFIED_LISTING {
+ verify_nip01_event(event.clone())?;
+ return Err(RadrootsReplicaEventsError::InvalidData(
+ "classified listing heads require profile-aware replica ingestion".to_string(),
+ ));
+ }
let decision = event_head_decision(exec, event)?;
if !decision.apply {
return Ok(RadrootsReplicaIngestOutcome::Skipped);
@@ -916,12 +1004,16 @@ fn event_head_decision(
exec: &dyn SqlExecutor,
event: &RadrootsEventEnvelope,
) -> Result<EventHeadDecision, RadrootsReplicaEventsError> {
- let candidate_result = match event_head_candidate_for_event(event) {
- Ok(candidate) => candidate,
- Err(err) => {
- return Err(RadrootsReplicaEventsError::InvalidData(format!(
- "event head contract mismatch: {err:?}"
- )));
+ let candidate_result = if event.kind_u32() == KIND_CLASSIFIED_LISTING {
+ event_head_candidate_for_class(event, RadrootsEventClass::Addressable)
+ } else {
+ match event_head_candidate_for_event(event) {
+ Ok(candidate) => candidate,
+ Err(err) => {
+ return Err(RadrootsReplicaEventsError::InvalidData(format!(
+ "event head contract mismatch: {err:?}"
+ )));
+ }
}
};
let candidate = match candidate_result {
@@ -1538,6 +1630,7 @@ mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
+ use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice,
RadrootsCoreUnit,
@@ -1556,6 +1649,7 @@ mod tests {
use radroots_event_codec::farm::list_sets as farm_list_sets;
use radroots_event_codec::list_set::encode as list_set_encode;
use radroots_event_codec::plot::encode as plot_encode;
+ use radroots_nostr::prelude::radroots_event_from_nostr;
use radroots_replica_schema::farm::IFarmFields;
use radroots_replica_schema::farm_gcs_location::IFarmGcsLocationFields;
use radroots_replica_schema::farm_member::IFarmMemberFields;
@@ -1571,6 +1665,7 @@ mod tests {
trade_product,
};
use radroots_sql_core::{ExecOutcome, SqlExecutor, SqlxSqliteExecutor};
+ use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_ALICE_SECRET_KEY_HEX};
fn test_event_envelope(
id: u64,
@@ -1910,7 +2005,6 @@ mod tests {
}
fn listing_event(
- id: u64,
author: &str,
created_at: u32,
d_tag: &str,
@@ -1918,11 +2012,9 @@ mod tests {
title: &str,
) -> RadrootsEventEnvelope {
let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA";
- test_event_envelope(
- id,
+ signed_listing_event(
author,
u64::from(created_at),
- KIND_CLASSIFIED_LISTING,
vec![
vec!["d".to_string(), d_tag.to_string()],
vec![
@@ -1965,6 +2057,78 @@ mod tests {
)
}
+ fn signed_listing_event(
+ author: &str,
+ created_at: u64,
+ tags: Vec<Vec<String>>,
+ content: String,
+ ) -> RadrootsEventEnvelope {
+ assert_eq!(
+ author, FIXTURE_ALICE_PUBLIC_KEY_HEX,
+ "listing tests must use the approved fixture signer"
+ );
+ let keys = Keys::parse(FIXTURE_ALICE_SECRET_KEY_HEX).expect("fixture signing key");
+ let tags = tags
+ .into_iter()
+ .map(|tag| Tag::parse(tag).expect("listing tag"))
+ .collect::<Vec<_>>();
+ let event = EventBuilder::new(
+ Kind::Custom(u16::try_from(KIND_CLASSIFIED_LISTING).expect("classified listing kind")),
+ content,
+ )
+ .tags(tags)
+ .allow_self_tagging()
+ .custom_created_at(Timestamp::from_secs(created_at))
+ .sign_with_keys(&keys)
+ .expect("signed listing event");
+ radroots_event_from_nostr(&event).expect("listing event adapter")
+ }
+
+ fn focused_listing_event(author: &str, created_at: u64, d_tag: &str) -> RadrootsEventEnvelope {
+ focused_listing_event_with_content(
+ author,
+ created_at,
+ d_tag,
+ "Carrots available this week.",
+ )
+ }
+
+ fn focused_listing_event_with_content(
+ author: &str,
+ created_at: u64,
+ d_tag: &str,
+ content: &str,
+ ) -> RadrootsEventEnvelope {
+ signed_listing_event(
+ author,
+ created_at,
+ vec![
+ vec!["d".to_string(), d_tag.to_string()],
+ vec!["title".to_string(), "Nantes Carrots".to_string()],
+ vec!["summary".to_string(), "Fresh bunches".to_string()],
+ vec!["published_at".to_string(), "1".to_string()],
+ vec!["location".to_string(), "Central Saanich, BC".to_string()],
+ vec!["price".to_string(), "3".to_string(), "CAD".to_string()],
+ vec!["radroots:price_unit".to_string(), "lb".to_string()],
+ vec!["status".to_string(), "active".to_string()],
+ ],
+ content.to_string(),
+ )
+ }
+
+ fn generic_listing_event(author: &str, created_at: u64, d_tag: &str) -> RadrootsEventEnvelope {
+ signed_listing_event(
+ author,
+ created_at,
+ vec![
+ vec!["d".to_string(), d_tag.to_string()],
+ vec!["title".to_string(), "Generic listing".to_string()],
+ vec!["price".to_string(), "3".to_string(), "CAD".to_string()],
+ ],
+ "A standards-compatible marker-free NIP-99 listing.".to_string(),
+ )
+ }
+
fn listing_decimal(raw: &str) -> RadrootsCoreDecimal {
raw.parse().expect("decimal")
}
@@ -2261,6 +2425,25 @@ mod tests {
}
#[test]
+ fn classified_listing_signature_rejection_precedes_transaction_acquisition() {
+ let executor = TxnExecutor {
+ inner: None,
+ begin_err: Some(SqlError::Internal),
+ commit_err: None,
+ rollback_count: Arc::new(AtomicUsize::new(0)),
+ };
+ let signed =
+ focused_listing_event(FIXTURE_ALICE_PUBLIC_KEY_HEX, 10, "pre-transaction-check");
+ let tampered = test_event_with_content(&signed, "tampered".to_string());
+
+ assert!(matches!(
+ radroots_replica_ingest_event_with_factory(&executor, &tampered, &FixedFactory),
+ Err(RadrootsReplicaEventsError::Verification(_))
+ ));
+ assert_eq!(executor.rollback_count.load(Ordering::SeqCst), 0);
+ }
+
+ #[test]
fn calendar_kind_uses_unsupported_transaction_path() {
let rollback_count = Arc::new(AtomicUsize::new(0));
let executor = TxnExecutor {
@@ -2627,21 +2810,14 @@ mod tests {
let exec = SqlxSqliteExecutor::open_memory().expect("db");
migrations::run_all_up(&exec).expect("migrations");
- let seller_pubkey = "c".repeat(64);
+ let seller_pubkey = FIXTURE_ALICE_PUBLIC_KEY_HEX.to_owned();
let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ";
let listing_addr = format!(
"{}:{}:{}",
KIND_CLASSIFIED_LISTING, seller_pubkey, listing_d_tag
);
- let mut active = listing_event(
- 500,
- &seller_pubkey,
- 10,
- listing_d_tag,
- "active",
- "Pasture Eggs",
- );
+ let mut active = listing_event(&seller_pubkey, 10, listing_d_tag, "active", "Pasture Eggs");
let mut active_tags = active.tags_as_vec();
active_tags.push(vec![
"radroots:discount".to_string(),
@@ -2658,9 +2834,9 @@ mod tests {
})
.to_string(),
]);
- active = test_event_with_parts(
- &active,
- active.kind_u32(),
+ active = signed_listing_event(
+ &seller_pubkey,
+ active.created_at_u64(),
active_tags,
active.content().to_owned(),
);
@@ -2699,14 +2875,7 @@ mod tests {
.is_some_and(|notes| notes.contains("listing_discounts"))
);
- let updated = listing_event(
- 501,
- &seller_pubkey,
- 11,
- listing_d_tag,
- "active",
- "Market Eggs",
- );
+ let updated = listing_event(&seller_pubkey, 11, listing_d_tag, "active", "Market Eggs");
assert_eq!(
radroots_replica_ingest_event(&exec, &updated).expect("listing update"),
RadrootsReplicaIngestOutcome::Applied
@@ -2727,14 +2896,7 @@ mod tests {
Some("bin-a")
);
- let archived = listing_event(
- 502,
- &seller_pubkey,
- 12,
- listing_d_tag,
- "archived",
- "Market Eggs",
- );
+ let archived = listing_event(&seller_pubkey, 12, listing_d_tag, "archived", "Market Eggs");
assert_eq!(
radroots_replica_ingest_event(&exec, &archived).expect("archived ingest"),
RadrootsReplicaIngestOutcome::Applied
@@ -2767,14 +2929,7 @@ mod tests {
.expect("state row");
assert_eq!(state.last_event_id, archived.id_str());
- let stale_active = listing_event(
- 499,
- &seller_pubkey,
- 11,
- listing_d_tag,
- "active",
- "Stale Eggs",
- );
+ let stale_active = listing_event(&seller_pubkey, 11, listing_d_tag, "active", "Stale Eggs");
assert_eq!(
radroots_replica_ingest_event(&exec, &stale_active).expect("stale ingest"),
RadrootsReplicaIngestOutcome::Skipped
@@ -2795,7 +2950,7 @@ mod tests {
let exec = SqlxSqliteExecutor::open_memory().expect("db");
migrations::run_all_up(&exec).expect("migrations");
- let seller_pubkey = "c".repeat(64);
+ let seller_pubkey = FIXTURE_ALICE_PUBLIC_KEY_HEX.to_owned();
let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAg";
let listing_addr = format!(
"{}:{}:{}",
@@ -2803,7 +2958,6 @@ mod tests {
);
let mut active = listing_event(
- 600,
&seller_pubkey,
10,
listing_d_tag,
@@ -2828,9 +2982,9 @@ mod tests {
tag[7] = "g".to_string();
}
}
- active = test_event_with_parts(
- &active,
- active.kind_u32(),
+ active = signed_listing_event(
+ &seller_pubkey,
+ active.created_at_u64(),
active_tags,
active.content().to_owned(),
);
@@ -2858,6 +3012,208 @@ mod tests {
}
#[test]
+ fn listing_raw_head_partition_prevents_projection_fallback_and_tampering() {
+ let exec = SqlxSqliteExecutor::open_memory().expect("db");
+ migrations::run_all_up(&exec).expect("migrations");
+
+ let seller = FIXTURE_ALICE_PUBLIC_KEY_HEX;
+ let d_tag = "AAAAAAAAAAAAAAAAAAAAAw";
+ let listing_addr = event_head_key(KIND_CLASSIFIED_LISTING, seller, d_tag);
+ let product_count = || {
+ trade_product::find_many(
+ &exec,
+ &ITradeProductFindMany {
+ filter: Some(trade_product_listing_addr_filter(&listing_addr)),
+ },
+ )
+ .expect("listing products")
+ .results
+ .len()
+ };
+
+ let active = listing_event(seller, 10, d_tag, "active", "Pasture Eggs");
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &active).expect("operational ingest"),
+ RadrootsReplicaIngestOutcome::Applied
+ );
+ assert_eq!(product_count(), 1);
+
+ let focused = focused_listing_event(seller, 20, d_tag);
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &focused).expect("focused ingest"),
+ RadrootsReplicaIngestOutcome::Excluded
+ );
+ assert_eq!(product_count(), 0);
+
+ let invalid_focused = signed_listing_event(
+ seller,
+ 25,
+ vec![
+ vec!["d".to_string(), d_tag.to_string()],
+ vec!["radroots:price_unit".to_string(), "lb".to_string()],
+ ],
+ "invalid focused listing".to_string(),
+ );
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &invalid_focused).expect("invalid focused ingest"),
+ RadrootsReplicaIngestOutcome::Rejected
+ );
+ assert_eq!(product_count(), 0);
+
+ let stale = listing_event(seller, 15, d_tag, "active", "Stale Eggs");
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &stale).expect("stale ingest"),
+ RadrootsReplicaIngestOutcome::Skipped
+ );
+ assert_eq!(product_count(), 0);
+
+ let active = listing_event(seller, 30, d_tag, "active", "Market Eggs");
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &active).expect("operational replacement"),
+ RadrootsReplicaIngestOutcome::Applied
+ );
+ assert_eq!(product_count(), 1);
+
+ let generic = generic_listing_event(seller, 40, d_tag);
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &generic).expect("generic ingest"),
+ RadrootsReplicaIngestOutcome::Excluded
+ );
+ assert_eq!(product_count(), 0);
+
+ let active = listing_event(seller, 50, d_tag, "active", "Market Eggs");
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &active).expect("operational replacement"),
+ RadrootsReplicaIngestOutcome::Applied
+ );
+ let ambiguous = signed_listing_event(
+ seller,
+ 60,
+ vec![
+ vec!["d".to_string(), d_tag.to_string()],
+ vec!["radroots:price_unit".to_string(), "lb".to_string()],
+ vec!["radroots:primary_bin".to_string(), "bin-a".to_string()],
+ ],
+ "ambiguous".to_string(),
+ );
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &ambiguous).expect("ambiguous ingest"),
+ RadrootsReplicaIngestOutcome::Rejected
+ );
+ assert_eq!(product_count(), 0);
+
+ let active = listing_event(seller, 70, d_tag, "active", "Market Eggs");
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &active).expect("operational replacement"),
+ RadrootsReplicaIngestOutcome::Applied
+ );
+ let malformed_operational = signed_listing_event(
+ seller,
+ 80,
+ vec![
+ vec!["d".to_string(), d_tag.to_string()],
+ vec!["radroots:primary_bin".to_string()],
+ ],
+ "malformed".to_string(),
+ );
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &malformed_operational)
+ .expect("malformed operational ingest"),
+ RadrootsReplicaIngestOutcome::Rejected
+ );
+ assert_eq!(product_count(), 0);
+
+ let active = listing_event(seller, 90, d_tag, "active", "Market Eggs");
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &active).expect("operational replacement"),
+ RadrootsReplicaIngestOutcome::Applied
+ );
+ assert_eq!(product_count(), 1);
+
+ let signed_replacement = focused_listing_event(seller, 100, d_tag);
+ assert!(matches!(
+ radroots_replica_ingest_event_head(&exec, &signed_replacement),
+ Err(RadrootsReplicaEventsError::InvalidData(ref message))
+ if message == "classified listing heads require profile-aware replica ingestion"
+ ));
+ assert_eq!(product_count(), 1);
+
+ let tampered = test_event_with_content(&signed_replacement, "tampered".to_string());
+ assert!(matches!(
+ radroots_replica_ingest_event(&exec, &tampered),
+ Err(RadrootsReplicaEventsError::Verification(_))
+ ));
+ assert!(matches!(
+ radroots_replica_ingest_event_head(&exec, &tampered),
+ Err(RadrootsReplicaEventsError::Verification(_))
+ ));
+ assert_eq!(product_count(), 1);
+
+ let state_before_replacement = nostr_event_head::find_one(
+ &exec,
+ &INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs {
+ on: NostrEventHeadQueryBindValues::Key {
+ key: listing_addr.clone(),
+ },
+ }),
+ )
+ .expect("event state")
+ .result
+ .expect("state row");
+ assert_eq!(state_before_replacement.last_event_id, active.id_str());
+
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &signed_replacement)
+ .expect("profile-aware focused replacement"),
+ RadrootsReplicaIngestOutcome::Excluded
+ );
+ assert_eq!(product_count(), 0);
+
+ let tied_operational =
+ listing_event(seller, 110, d_tag, "active", "Equal-time Market Eggs");
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &tied_operational)
+ .expect("equal-time operational head"),
+ RadrootsReplicaIngestOutcome::Applied
+ );
+ assert_eq!(product_count(), 1);
+
+ let tied_focused = (0..256)
+ .map(|nonce| {
+ focused_listing_event_with_content(
+ seller,
+ 110,
+ d_tag,
+ &format!("Equal-time focused replacement {nonce}"),
+ )
+ })
+ .find(|candidate| candidate.id_str() < tied_operational.id_str())
+ .expect("deterministic lower-id focused candidate");
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &tied_focused)
+ .expect("lower-id focused replacement"),
+ RadrootsReplicaIngestOutcome::Excluded
+ );
+ assert_eq!(product_count(), 0);
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &tied_operational)
+ .expect("higher-id equal-time operational replay"),
+ RadrootsReplicaIngestOutcome::Skipped
+ );
+
+ let final_state = nostr_event_head::find_one(
+ &exec,
+ &INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs {
+ on: NostrEventHeadQueryBindValues::Key { key: listing_addr },
+ }),
+ )
+ .expect("final event state")
+ .result
+ .expect("final state row");
+ assert_eq!(final_state.last_event_id, tied_focused.id_str());
+ }
+
+ #[test]
fn upsert_location_none_paths_are_ok() {
let exec = SqlxSqliteExecutor::open_memory().expect("db");
migrations::run_all_up(&exec).expect("migrations");
diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml
@@ -27,6 +27,7 @@ std = [
"radroots_core/std",
"radroots_event/std",
"radroots_event_codec/std",
+ "radroots_event_codec/nostr",
]
event_store = [
"std",
@@ -77,6 +78,7 @@ thiserror = { workspace = true }
[dev-dependencies]
nostr = { workspace = true }
+radroots_test_fixtures = { workspace = true }
radroots_nostr = { workspace = true, default-features = false, features = [
"std",
"events",
diff --git a/crates/trade/src/operational_listing/mutation.rs b/crates/trade/src/operational_listing/mutation.rs
@@ -174,7 +174,6 @@ mod tests {
RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
use radroots_event::{
- RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
contract::validate_event_contract_shape,
farm::RadrootsFarmRef,
ids::{
@@ -190,6 +189,11 @@ mod tests {
},
resource_area::RadrootsResourceAreaRef,
};
+ use radroots_event_codec::verification::verify_nip01_event;
+ use radroots_nostr::prelude::{
+ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft,
+ };
+ use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_ALICE_SECRET_KEY_HEX};
use crate::operational_listing::draft::RadrootsOperationalListingCanonicalEdit;
use crate::operational_listing::validation::validate_operational_listing_event;
@@ -200,7 +204,7 @@ mod tests {
build_operational_listing_mutation_draft,
};
- const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX;
fn d_tag(raw: &str) -> RadrootsDTag {
RadrootsDTag::parse(raw).expect("d tag")
@@ -482,19 +486,15 @@ mod tests {
let draft =
build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
- let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
- id: draft.expected_event_id_str().to_owned(),
- author: draft.expected_pubkey_str().to_owned(),
- created_at: draft.created_at_u64(),
- kind: draft.kind_u32(),
- tags: draft.tags_as_vec(),
- content: draft.content().to_owned(),
- sig: "f".repeat(128),
- })
- .expect("listing event");
- validate_event_contract_shape(&event, OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID)
+ let keys = RadrootsNostrKeys::new(
+ RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX)
+ .expect("fixture secret key"),
+ );
+ let signed = radroots_nostr_sign_frozen_draft(&keys, &draft).expect("signed listing event");
+ validate_event_contract_shape(signed.envelope(), OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID)
.expect("operational listing contract");
- let validated = validate_operational_listing_event(&event).expect("validated listing");
+ let verified = verify_nip01_event(signed.envelope().clone()).expect("verified listing");
+ let validated = validate_operational_listing_event(&verified).expect("validated listing");
assert_eq!(validated.seller_pubkey, SELLER);
assert!(validated.listing_addr.contains(&format!(":{SELLER}:")));
diff --git a/crates/trade/src/operational_listing/validation.rs b/crates/trade/src/operational_listing/validation.rs
@@ -10,7 +10,7 @@ use radroots_core::{
RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit,
};
use radroots_event::{
- RadrootsEventEnvelope,
+ classified_listing::{RadrootsClassifiedListingPartition, classify_classified_listing_tags},
ids::RadrootsClassifiedListingAddress,
kinds::is_classified_listing_kind,
location::{has_textual_locality, is_public_geohash5},
@@ -21,7 +21,10 @@ use radroots_event::{
trade_validation::RadrootsOperationalListingValidationError as OperationalListingValidationError,
};
-use radroots_event_codec::operational_listing::decode::operational_listing_from_nostr_event;
+use radroots_event_codec::{
+ operational_listing::decode::operational_listing_from_nostr_event,
+ verification::RadrootsSignatureVerifiedEvent,
+};
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
@@ -43,14 +46,32 @@ pub struct RadrootsOperationalListingTradeProjection {
pub listing: RadrootsOperationalListing,
}
+/// Validates a signature-verified Operational Listing event.
+///
+/// A plain envelope cannot cross this boundary:
+///
+/// ```compile_fail
+/// use radroots_event::RadrootsEventEnvelope;
+/// use radroots_trade::operational_listing::validation::validate_operational_listing_event;
+///
+/// fn validate_unverified(event: &RadrootsEventEnvelope) {
+/// let _ = validate_operational_listing_event(event);
+/// }
+/// ```
pub fn validate_operational_listing_event(
- event: &RadrootsEventEnvelope,
+ verified_event: &RadrootsSignatureVerifiedEvent,
) -> Result<RadrootsOperationalListingTradeProjection, OperationalListingValidationError> {
+ let event = verified_event.event();
if !is_classified_listing_kind(event.kind_u32()) {
return Err(OperationalListingValidationError::InvalidKind {
kind: event.kind_u32(),
});
}
+ if classify_classified_listing_tags(event.tags())
+ != RadrootsClassifiedListingPartition::OperationalListing
+ {
+ return Err(OperationalListingValidationError::InvalidProfile);
+ }
let listing = operational_listing_from_nostr_event(event)
.map_err(|error| OperationalListingValidationError::ParseError { error })?;
@@ -186,12 +207,13 @@ fn validate_listing_location_geohash(
#[cfg(test)]
mod tests {
use super::{OperationalListingValidationError, validate_operational_listing_event};
+ use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
use radroots_event::{
- RadrootsEventEnvelope,
+ RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
farm::RadrootsFarmRef,
ids::{RadrootsDTag, RadrootsInventoryBinId},
kinds::KIND_CLASSIFIED_LISTING,
@@ -201,9 +223,15 @@ mod tests {
RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation,
},
};
+ use radroots_event_codec::verification::{RadrootsSignatureVerifiedEvent, verify_nip01_event};
+ use radroots_nostr::prelude::radroots_event_from_nostr;
+ use radroots_test_fixtures::{
+ FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_ALICE_SECRET_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX,
+ FIXTURE_BOB_SECRET_KEY_HEX,
+ };
- const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
- const OTHER_SELLER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+ const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX;
+ const OTHER_SELLER: &str = FIXTURE_BOB_PUBLIC_KEY_HEX;
fn d_tag(raw: &str) -> RadrootsDTag {
RadrootsDTag::parse(raw).expect("d tag")
@@ -275,7 +303,7 @@ mod tests {
}
}
- fn base_event(listing: &RadrootsOperationalListing) -> RadrootsEventEnvelope {
+ fn base_event(listing: &RadrootsOperationalListing) -> RadrootsSignatureVerifiedEvent {
let mut tags = vec![
vec!["d".into(), listing.d_tag.to_string()],
vec!["p".into(), listing.farm.pubkey.clone()],
@@ -381,17 +409,28 @@ mod tests {
kind: u32,
tags: Vec<Vec<String>>,
content: String,
- ) -> RadrootsEventEnvelope {
- RadrootsEventEnvelope::new(radroots_event::RadrootsEventEnvelopeParts {
- id: "9".repeat(64),
- author: author.to_string(),
- created_at: 0,
- kind,
- tags,
+ ) -> RadrootsSignatureVerifiedEvent {
+ let secret = match author {
+ SELLER => FIXTURE_ALICE_SECRET_KEY_HEX,
+ OTHER_SELLER => FIXTURE_BOB_SECRET_KEY_HEX,
+ _ => panic!("test author must be an approved fixture identity"),
+ };
+ let keys = Keys::parse(secret).expect("fixture signing key");
+ let tags = tags
+ .into_iter()
+ .map(|tag| Tag::parse(tag).expect("test tag"))
+ .collect::<Vec<_>>();
+ let event = EventBuilder::new(
+ Kind::Custom(u16::try_from(kind).expect("test kind")),
content,
- sig: "f".repeat(128),
- })
- .expect("event")
+ )
+ .tags(tags)
+ .allow_self_tagging()
+ .custom_created_at(Timestamp::from_secs(1))
+ .sign_with_keys(&keys)
+ .expect("signed test event");
+ let envelope = radroots_event_from_nostr(&event).expect("event adapter");
+ verify_nip01_event(envelope).expect("verified test event")
}
fn assert_validation_err(
@@ -416,7 +455,7 @@ mod tests {
let event = event_with_parts(
SELLER,
30403,
- base_event(&listing).tags_as_vec(),
+ base_event(&listing).event().tags_as_vec(),
String::new(),
);
let err = validate_operational_listing_event(&event).unwrap_err();
@@ -430,12 +469,7 @@ mod tests {
fn validate_listing_rejects_missing_d_tag() {
let event = event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, Vec::new(), String::new());
let err = validate_operational_listing_event(&event).unwrap_err();
- assert_eq!(
- err,
- OperationalListingValidationError::ParseError {
- error: radroots_event::operational_listing::RadrootsOperationalListingParseError::MissingTag("d".to_string())
- }
- );
+ assert_eq!(err, OperationalListingValidationError::InvalidProfile);
}
#[test]
@@ -451,6 +485,7 @@ mod tests {
vec!["title".into(), "Coffee".into()],
vec!["category".into(), "coffee".into()],
vec!["summary".into(), "Single origin".into()],
+ vec!["radroots:primary_bin".into(), "bin-1".into()],
vec![
"quantity".into(),
"1".into(),
@@ -486,7 +521,7 @@ mod tests {
let event = event_with_parts(
OTHER_SELLER,
KIND_CLASSIFIED_LISTING,
- base_event(&listing).tags_as_vec(),
+ base_event(&listing).event().tags_as_vec(),
String::new(),
);
let err = validate_operational_listing_event(&event).unwrap_err();
@@ -505,7 +540,12 @@ mod tests {
#[test]
fn validate_listing_rejects_invalid_kind() {
let listing = base_listing();
- let event = event_with_parts(SELLER, 0, base_event(&listing).tags_as_vec(), String::new());
+ let event = event_with_parts(
+ SELLER,
+ 0,
+ base_event(&listing).event().tags_as_vec(),
+ String::new(),
+ );
let err = validate_operational_listing_event(&event).unwrap_err();
assert_eq!(
err,
@@ -521,6 +561,24 @@ mod tests {
}
#[test]
+ fn tampered_envelope_cannot_reach_operational_validation() {
+ let verified = base_event(&base_listing());
+ let event = verified.into_event();
+ let tampered = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: event.id_str().to_owned(),
+ author: event.author_str().to_owned(),
+ created_at: event.created_at_u64(),
+ kind: event.kind_u32(),
+ tags: event.tags_as_vec(),
+ content: "tampered".to_owned(),
+ sig: event.sig_str().to_owned(),
+ })
+ .expect("well-shaped tampered envelope");
+
+ assert!(verify_nip01_event(tampered).is_err());
+ }
+
+ #[test]
fn validate_listing_rejects_missing_description() {
let mut listing = base_listing();
listing.product.summary = Some(" ".into());
@@ -695,6 +753,7 @@ mod tests {
fn validation_error_display_covers_all_variants() {
let errors = vec![
OperationalListingValidationError::InvalidKind { kind: 9 },
+ OperationalListingValidationError::InvalidProfile,
OperationalListingValidationError::MissingListingId,
OperationalListingValidationError::ListingEventNotFound {
listing_addr: "addr".into(),
diff --git a/crates/trade/src/projection.rs b/crates/trade/src/projection.rs
@@ -4,13 +4,19 @@ use std::collections::{BTreeMap, BTreeSet};
use radroots_event::{
RadrootsEventEnvelope, RadrootsEventEnvelopeError, RadrootsEventEnvelopeParts,
+ classified_listing::{
+ RadrootsClassifiedListingPartition, classify_classified_listing_tags,
+ },
ids::{RadrootsEventId, RadrootsIdParseError, RadrootsClassifiedListingAddress, RadrootsOrderId},
kinds::{KIND_TRADE_VALIDATION_RECEIPT, is_classified_listing_kind, is_order_event_kind},
operational_listing::{RadrootsOperationalListingAvailability, RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingStatus},
order::RadrootsOrderEventType,
tags::TAG_D,
};
-use radroots_event_codec::order::{RadrootsOrderEnvelopeParseError, order_event_context_from_tags};
+use radroots_event_codec::{
+ order::{RadrootsOrderEnvelopeParseError, order_event_context_from_tags},
+ verification::{RadrootsNip01VerificationError, verify_nip01_event},
+};
use radroots_event_store::{
RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventStore, RadrootsEventStoreError,
RadrootsProjectionCursor, RadrootsStoredEvent,
@@ -63,6 +69,11 @@ pub enum RadrootsTradeProjectionError {
event_id: String,
source: RadrootsEventEnvelopeError,
},
+ #[error("stored event {event_id} failed NIP-01 verification: {source}")]
+ StoredEventVerification {
+ event_id: String,
+ source: RadrootsNip01VerificationError,
+ },
#[error("stored event {event_id} created_at {created_at} exceeds sqlite integer range")]
StoredCreatedAtRange { event_id: String, created_at: u64 },
#[error("stored listing event {event_id} failed validation: {source}")]
@@ -222,7 +233,18 @@ pub async fn refresh_product_projections(
transport_observation_count_for_event(store, &stored_event.event_id).await?;
if is_classified_listing_kind(stored_event.kind) {
let event = stored_event_to_nostr_event(stored_event)?;
- let listing = validate_operational_listing_event(&event).map_err(|source| {
+ if classify_classified_listing_tags(event.tags())
+ != RadrootsClassifiedListingPartition::OperationalListing
+ {
+ continue;
+ }
+ let verified_event = verify_nip01_event(event).map_err(|source| {
+ RadrootsTradeProjectionError::StoredEventVerification {
+ event_id: stored_event.event_id.clone(),
+ source,
+ }
+ })?;
+ let listing = validate_operational_listing_event(&verified_event).map_err(|source| {
RadrootsTradeProjectionError::ListingValidation {
event_id: stored_event.event_id.clone(),
source,
diff --git a/crates/trade/tests/fixtures/validate_operational_listing_event.v1.json b/crates/trade/tests/fixtures/validate_operational_listing_event.v1.json
@@ -217,6 +217,86 @@
},
"message": "missing listing inventory"
}
+ },
+ {
+ "id": "trade_validation_validate_operational_listing_event_focused_profile_004",
+ "kind": "trade_validation.validate_operational_listing_event.invalid",
+ "input": {
+ "event": {
+ "id": "854bbadc6834830ba084fa1e85a40361d196b12e743e0c466675816bfcd90462",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1700000100,
+ "kind": 30402,
+ "tags": [
+ ["d", "food-focused"],
+ ["title", "Nantes Carrots"],
+ ["summary", "Fresh bunches"],
+ ["published_at", "1700000000"],
+ ["location", "Central Saanich, BC"],
+ ["price", "3", "CAD"],
+ ["radroots:price_unit", "lb"],
+ ["status", "active"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "bd96b50265a55196efd5cf9c9d81389f7ec9ab4fcbc6b69babd8c8bba05fdae5f4d8f0d813c22a2dd9dba1582a4fc2d1256b3a4df0d954bb4e9a496c6256b714"
+ }
+ },
+ "expected": {
+ "error": {
+ "kind": "invalid_profile"
+ },
+ "message": "classified listing is not an Operational Listing profile"
+ }
+ },
+ {
+ "id": "trade_validation_validate_operational_listing_event_generic_nip99_005",
+ "kind": "trade_validation.validate_operational_listing_event.invalid",
+ "input": {
+ "event": {
+ "id": "a0ec5543e05cb0a70b8dbd8b94a92b4880145ce072b3843499c1216c3be0491c",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1700000100,
+ "kind": 30402,
+ "tags": [
+ ["d", "generic-nip99"],
+ ["title", "Generic produce listing"],
+ ["price", "3", "CAD"]
+ ],
+ "content": "Standards-compatible marker-free listing.",
+ "sig": "eaef63ec0633a825d7534adf635b1c7910992dabbbc51f74986d3bffb15a5e3a1e62b5310e59cb808941029ee6252b80b7011ee18d57e1ce71c87dfc2193e08c"
+ }
+ },
+ "expected": {
+ "error": {
+ "kind": "invalid_profile"
+ },
+ "message": "classified listing is not an Operational Listing profile"
+ }
+ },
+ {
+ "id": "trade_validation_validate_operational_listing_event_ambiguous_profile_006",
+ "kind": "trade_validation.validate_operational_listing_event.invalid",
+ "input": {
+ "event": {
+ "id": "836ab6059aba9cbf2cae36b59b2b0573db8b9f8b109e41a0aaba81f5723d2d02",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1700000100,
+ "kind": 30402,
+ "tags": [
+ ["d", "mixed-markers"],
+ ["radroots:price_unit", "lb"],
+ ["radroots:primary_bin", "bin-a"]
+ ],
+ "content": "Mixed marker listing.",
+ "sig": "a7039386c4a976904bf14265c8c9c73d602a0e68457715a8af90feeee5e396d98e3874eaa2d58be9d02d7cd2616c7a411ef8057dd2b8266e40504dbec004ce9c"
+ }
+ },
+ "expected": {
+ "error": {
+ "kind": "invalid_profile"
+ },
+ "message": "classified listing is not an Operational Listing profile"
+ }
}
]
}
diff --git a/crates/trade/tests/operational_listing_conformance.rs b/crates/trade/tests/operational_listing_conformance.rs
@@ -3,6 +3,7 @@
use nostr::secp256k1::Message;
use nostr::{Event as NostrEvent, JsonUtil, Keys, SECP256K1};
use radroots_event::{RadrootsNip01EventWire, ids::RadrootsIdParseError};
+use radroots_event_codec::verification::{RadrootsSignatureVerifiedEvent, verify_nip01_event};
use radroots_nostr::prelude::radroots_event_from_nostr;
use radroots_trade::operational_listing::{
parse_classified_listing_address, validation::validate_operational_listing_event,
@@ -31,10 +32,13 @@ const ADDRESS_VECTOR_IDS: [&str; 7] = [
"trade_parse_classified_listing_address_invalid_d_tag_007",
];
-const VALIDATION_VECTOR_IDS: [&str; 3] = [
+const VALIDATION_VECTOR_IDS: [&str; 6] = [
"trade_validation_validate_operational_listing_event_valid_001",
"trade_validation_validate_operational_listing_event_invalid_seller_002",
"trade_validation_validate_operational_listing_event_missing_inventory_003",
+ "trade_validation_validate_operational_listing_event_focused_profile_004",
+ "trade_validation_validate_operational_listing_event_generic_nip99_005",
+ "trade_validation_validate_operational_listing_event_ambiguous_profile_006",
];
#[derive(Debug, Deserialize)]
@@ -199,7 +203,7 @@ fn address_error_value(error: &RadrootsIdParseError) -> Value {
}
}
-fn verified_event(vector: &Vector, keys: &Keys) -> radroots_event::RadrootsEventEnvelope {
+fn verified_event(vector: &Vector, keys: &Keys) -> RadrootsSignatureVerifiedEvent {
assert_object_keys(&vector.input, &["event"], "input", &vector.id);
let event_value = vector
.input
@@ -262,16 +266,17 @@ fn verified_event(vector: &Vector, keys: &Keys) -> radroots_event::RadrootsEvent
let adapted = radroots_event_from_nostr(&event)
.unwrap_or_else(|error| panic!("{} failed Nostr adapter conversion: {error}", vector.id));
assert_eq!(envelope, adapted, "{} conversion drift", vector.id);
- envelope
+ verify_nip01_event(envelope)
+ .unwrap_or_else(|error| panic!("{} failed typed verification: {error}", vector.id))
}
-fn validation_valid(vector: &Vector, event: &radroots_event::RadrootsEventEnvelope) {
+fn validation_valid(vector: &Vector, event: &RadrootsSignatureVerifiedEvent) {
assert_object_keys(&vector.expected, &["projection"], "expected", &vector.id);
let projection = validate_operational_listing_event(event)
.unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
assert_eq!(
projection.listing.farm.pubkey,
- event.author_str(),
+ event.event().author_str(),
"{} decoded farm author drift",
vector.id
);
@@ -279,7 +284,7 @@ fn validation_valid(vector: &Vector, event: &radroots_event::RadrootsEventEnvelo
assert_eq!(actual, vector.expected["projection"], "{}", vector.id);
}
-fn validation_invalid(vector: &Vector, event: &radroots_event::RadrootsEventEnvelope) {
+fn validation_invalid(vector: &Vector, event: &RadrootsSignatureVerifiedEvent) {
assert_object_keys(
&vector.expected,
&["error", "message"],
diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs
@@ -1086,6 +1086,11 @@ pub struct ReplicaContractPolicy {
pub forbid_legacy_alias_identifiers: bool,
pub profile_event_emission: String,
pub unknown_sync_request_fields: String,
+ pub classified_listing_signature_verification: String,
+ pub classified_listing_head_selection: String,
+ pub classified_listing_operational_projection: String,
+ pub classified_listing_excluded_or_rejected_head: String,
+ pub classified_listing_head_only_ingest: String,
}
#[derive(Debug, Deserialize)]
@@ -2412,7 +2417,7 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 42] = [
EventBoundaryExpectation {
domain: "food_availability",
kind: "30402",
- radroots_type: "RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection / RadrootsAdmittedFoodAvailabilityEvent",
+ radroots_type: "RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection / RadrootsAdmittedFoodAvailabilityEvent / RadrootsNostrFoodAvailabilityEventBuilder",
rpc_methods: &[
"food_availability.build_authored_draft",
"food_availability.project_verified_event",
@@ -4637,6 +4642,46 @@ fn validate_replica_contract(bundle: &ContractBundle, workspace_root: &Path) ->
if replica.policy.unknown_sync_request_fields != "reject" {
return Err("replica policy.unknown_sync_request_fields must be reject".to_string());
}
+ for (field, actual, expected) in [
+ (
+ "classified_listing_signature_verification",
+ replica
+ .policy
+ .classified_listing_signature_verification
+ .as_str(),
+ "required_before_state",
+ ),
+ (
+ "classified_listing_head_selection",
+ replica.policy.classified_listing_head_selection.as_str(),
+ "raw_before_profile",
+ ),
+ (
+ "classified_listing_operational_projection",
+ replica
+ .policy
+ .classified_listing_operational_projection
+ .as_str(),
+ "operational_partition_only",
+ ),
+ (
+ "classified_listing_excluded_or_rejected_head",
+ replica
+ .policy
+ .classified_listing_excluded_or_rejected_head
+ .as_str(),
+ "remove_projection_and_advance",
+ ),
+ (
+ "classified_listing_head_only_ingest",
+ replica.policy.classified_listing_head_only_ingest.as_str(),
+ "reject_require_profile_aware",
+ ),
+ ] {
+ if actual != expected {
+ return Err(format!("replica policy.{field} must be {expected}"));
+ }
+ }
if replica.transfer.version != REPLICA_TRANSFER_VERSION {
return Err(format!(
@@ -7021,6 +7066,11 @@ deterministic_emit_and_ingest = true
forbid_legacy_alias_identifiers = true
profile_event_emission = "excluded"
unknown_sync_request_fields = "reject"
+classified_listing_signature_verification = "required_before_state"
+classified_listing_head_selection = "raw_before_profile"
+classified_listing_operational_projection = "operational_partition_only"
+classified_listing_excluded_or_rejected_head = "remove_projection_and_advance"
+classified_listing_head_only_ingest = "reject_require_profile_aware"
[transfer]
version = 2
@@ -7801,6 +7851,47 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"]
assert_replica_error("unknown_sync_request_fields must be reject", |bundle| {
bundle.replica.policy.unknown_sync_request_fields = "ignore".to_string();
});
+ assert_replica_error(
+ "classified_listing_signature_verification must be required_before_state",
+ |bundle| {
+ bundle
+ .replica
+ .policy
+ .classified_listing_signature_verification = "unchecked".to_string();
+ },
+ );
+ assert_replica_error(
+ "classified_listing_head_selection must be raw_before_profile",
+ |bundle| {
+ bundle.replica.policy.classified_listing_head_selection =
+ "profile_before_raw".to_string();
+ },
+ );
+ assert_replica_error(
+ "classified_listing_operational_projection must be operational_partition_only",
+ |bundle| {
+ bundle
+ .replica
+ .policy
+ .classified_listing_operational_projection = "all_partitions".to_string();
+ },
+ );
+ assert_replica_error(
+ "classified_listing_excluded_or_rejected_head must be remove_projection_and_advance",
+ |bundle| {
+ bundle
+ .replica
+ .policy
+ .classified_listing_excluded_or_rejected_head = "retain_projection".to_string();
+ },
+ );
+ assert_replica_error(
+ "classified_listing_head_only_ingest must be reject_require_profile_aware",
+ |bundle| {
+ bundle.replica.policy.classified_listing_head_only_ingest =
+ "allow_head_only".to_string();
+ },
+ );
assert_replica_error("transfer.version must be 2", |bundle| {
bundle.replica.transfer.version = 1;
});