lib

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

commit fa59bd9214b646cdb441a2a65105d0d01a5589af
parent 274ad76f0b72f49631e5471e9e573cb887f38f22
Author: triesap <tyson@radroots.org>
Date:   Sat, 11 Apr 2026 16:47:06 +0000

contract: guard canonical event boundary rows

Diffstat:
Mcrates/xtask/src/contract.rs | 872+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/xtask/src/main.rs | 1+
2 files changed, 873 insertions(+), 0 deletions(-)

diff --git a/crates/xtask/src/contract.rs b/crates/xtask/src/contract.rs @@ -13,6 +13,11 @@ const ROOT_RELEASE_POLICY_RELATIVE: &str = const CONFORMANCE_ROOT_RELATIVE: &str = "spec/conformance"; const CONFORMANCE_SCHEMA_RELATIVE: &str = "spec/conformance/schema/vector.schema.json"; const RELEASE_POLICY_ENV: &str = "RADROOTS_MOUNTED_RUST_CRATE_PUBLISH_POLICY"; +const EVENT_BOUNDARY_MATRIX_ENV: &str = "RADROOTS_EVENT_BOUNDARY_MATRIX"; +const EVENT_BOUNDARY_MATRIX_RELATIVES: [&str; 2] = [ + "dev/docs/radroots/radrootsd/spec-coverage.md", + "docs/radroots/radrootsd/spec-coverage.md", +]; #[derive(Debug, Deserialize)] pub struct ContractManifest { @@ -225,6 +230,637 @@ struct CoverageRequiredSection { crates: Vec<String>, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct EventBoundaryRow { + domain: String, + kind: String, + radroots_type: String, + rpc_methods: BTreeSet<String>, +} + +#[derive(Clone, Copy)] +struct EventBoundarySourceWitness { + relative_path: &'static str, + required_fragments: &'static [&'static str], +} + +#[derive(Clone, Copy)] +struct EventBoundaryExpectation { + domain: &'static str, + kind: &'static str, + radroots_type: &'static str, + rpc_methods: &'static [&'static str], + witnesses: &'static [EventBoundarySourceWitness], +} + +const PROFILE_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/profile.rs", + required_fragments: &["pub struct RadrootsProfile"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_PROFILE: u32 = 0;"], + }, +]; + +const FOLLOW_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/follow.rs", + required_fragments: &["pub struct RadrootsFollow"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_FOLLOW: u32 = 3;"], + }, +]; + +const POST_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/post.rs", + required_fragments: &["pub struct RadrootsPost"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_POST: u32 = 1;"], + }, +]; + +const COMMENT_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/comment.rs", + required_fragments: &["pub struct RadrootsComment"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_COMMENT: u32 = 1111;"], + }, +]; + +const REACTION_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/reaction.rs", + required_fragments: &["pub struct RadrootsReaction"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_REACTION: u32 = 7;"], + }, +]; + +const SEAL_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/seal.rs", + required_fragments: &["pub struct RadrootsSeal"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_SEAL: u32 = 13;"], + }, +]; + +const MESSAGE_WITNESSES: [EventBoundarySourceWitness; 4] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/message.rs", + required_fragments: &["pub struct RadrootsMessage"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_MESSAGE: u32 = 14;"], + }, + EventBoundarySourceWitness { + relative_path: "crates/nostr/src/nip17.rs", + required_fragments: &[ + "pub async fn radroots_nostr_wrap_message<T>(", + "KIND_MESSAGE =>", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/nostr/src/lib.rs", + required_fragments: &["radroots_nostr_wrap_message"], + }, +]; + +const MESSAGE_FILE_WITNESSES: [EventBoundarySourceWitness; 4] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/message_file.rs", + required_fragments: &["pub struct RadrootsMessageFile"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_MESSAGE_FILE: u32 = 15;"], + }, + EventBoundarySourceWitness { + relative_path: "crates/nostr/src/nip17.rs", + required_fragments: &[ + "pub async fn radroots_nostr_wrap_message_file<T>(", + "KIND_MESSAGE_FILE =>", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/nostr/src/lib.rs", + required_fragments: &["radroots_nostr_wrap_message_file"], + }, +]; + +const GIFT_WRAP_WITNESSES: [EventBoundarySourceWitness; 4] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/gift_wrap.rs", + required_fragments: &["pub struct RadrootsGiftWrap"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_GIFT_WRAP: u32 = 1059;"], + }, + EventBoundarySourceWitness { + relative_path: "crates/nostr/src/nip17.rs", + required_fragments: &["pub async fn radroots_nostr_unwrap_gift_wrap<T>("], + }, + EventBoundarySourceWitness { + relative_path: "crates/nostr/src/lib.rs", + required_fragments: &["radroots_nostr_unwrap_gift_wrap"], + }, +]; + +const LIST_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/list.rs", + required_fragments: &["pub struct RadrootsList"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &[ + "pub const KIND_LIST_MUTE: u32 = 10000;", + "pub const KIND_LIST_GOOD_WIKI_RELAYS: u32 = 10102;", + ], + }, +]; + +const LIST_SET_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/list_set.rs", + required_fragments: &["pub struct RadrootsListSet"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &[ + "pub const KIND_LIST_SET_FOLLOW: u32 = 30000;", + "pub const KIND_LIST_SET_MEDIA_STARTER_PACK: u32 = 39092;", + ], + }, +]; + +const APP_DATA_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/app_data.rs", + required_fragments: &["pub struct RadrootsAppData"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_APP_DATA: u32 = 30078;"], + }, +]; + +const APP_HANDLER_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_APPLICATION_HANDLER: u32 = 31990;"], + }, + EventBoundarySourceWitness { + relative_path: "crates/nostr/src/events/application_handler.rs", + required_fragments: &["pub fn radroots_nostr_build_application_handler_event("], + }, +]; + +const FARM_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/farm.rs", + required_fragments: &["pub struct RadrootsFarm"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_FARM: u32 = 30340;"], + }, +]; + +const PLOT_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/plot.rs", + required_fragments: &["pub struct RadrootsPlot"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_PLOT: u32 = 30350;"], + }, +]; + +const COOP_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/coop.rs", + required_fragments: &["pub struct RadrootsCoop"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_COOP: u32 = 30360;"], + }, +]; + +const DOCUMENT_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/document.rs", + required_fragments: &["pub struct RadrootsDocument"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_DOCUMENT: u32 = 30361;"], + }, +]; + +const RESOURCE_AREA_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/resource_area.rs", + required_fragments: &["pub struct RadrootsResourceArea"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_RESOURCE_AREA: u32 = 30370;"], + }, +]; + +const RESOURCE_CAP_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/resource_cap.rs", + required_fragments: &["pub struct RadrootsResourceHarvestCap"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_RESOURCE_HARVEST_CAP: u32 = 30371;"], + }, +]; + +const LISTING_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/listing.rs", + required_fragments: &["pub struct RadrootsListing"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_LISTING: u32 = 30402;"], + }, +]; + +const LISTING_DRAFT_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/listing.rs", + required_fragments: &["pub struct RadrootsListing"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_LISTING_DRAFT: u32 = 30403;"], + }, +]; + +const DVM_REQUEST_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/job_request.rs", + required_fragments: &["pub struct RadrootsJobRequest"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &[ + "pub const KIND_JOB_REQUEST_MIN: u32 = 5000;", + "pub const KIND_JOB_REQUEST_MAX: u32 = 5999;", + ], + }, +]; + +const DVM_RESULT_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/job_result.rs", + required_fragments: &["pub struct RadrootsJobResult"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &[ + "pub const KIND_JOB_RESULT_MIN: u32 = 6000;", + "pub const KIND_JOB_RESULT_MAX: u32 = 6999;", + ], + }, +]; + +const DVM_FEEDBACK_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/job_feedback.rs", + required_fragments: &["pub struct RadrootsJobFeedback"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &["pub const KIND_JOB_FEEDBACK: u32 = 7000;"], + }, +]; + +const TRADE_LISTING_WITNESSES: [EventBoundarySourceWitness; 4] = [ + EventBoundarySourceWitness { + relative_path: "crates/trade/src/listing/dvm.rs", + required_fragments: &["pub struct TradeListingEnvelope<T>"], + }, + EventBoundarySourceWitness { + relative_path: "crates/trade/src/listing/kinds.rs", + required_fragments: &[ + "pub const KIND_TRADE_LISTING_VALIDATE_REQ: u16 = 5321;", + "pub const KIND_TRADE_LISTING_VALIDATE_RES: u16 = 6321;", + "pub const KIND_TRADE_LISTING_ORDER_REQ: u16 = 5322;", + "pub const KIND_TRADE_LISTING_ORDER_RES: u16 = 6322;", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/trade.rs", + required_fragments: &["pub struct RadrootsTradeEnvelope<T>"], + }, + EventBoundarySourceWitness { + relative_path: "crates/events/src/kinds.rs", + required_fragments: &[ + "pub const KIND_TRADE_LISTING_VALIDATE_REQ: u32 = 5321;", + "pub const KIND_TRADE_LISTING_ORDER_REQ: u32 = KIND_TRADE_ORDER_REQUEST;", + ], + }, +]; + +const RELAY_DOC_WITNESSES: [EventBoundarySourceWitness; 2] = [ + EventBoundarySourceWitness { + relative_path: "crates/events/src/relay_document.rs", + required_fragments: &["pub struct RadrootsRelayDocument"], + }, + EventBoundarySourceWitness { + relative_path: "crates/nostr/src/nip11.rs", + required_fragments: &[ + "pub async fn fetch_nip11(ws_url: &str) -> Option<RadrootsRelayDocument>", + ], + }, +]; + +const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 26] = [ + EventBoundaryExpectation { + domain: "profile", + kind: "0", + radroots_type: "RadrootsProfile", + rpc_methods: &[ + "events.profile.publish", + "events.profile.list", + "events.profile.get", + ], + witnesses: &PROFILE_WITNESSES, + }, + EventBoundaryExpectation { + domain: "follow", + kind: "3", + radroots_type: "RadrootsFollow", + rpc_methods: &[ + "events.follow.publish", + "events.follow.list", + "events.follow.get", + ], + witnesses: &FOLLOW_WITNESSES, + }, + EventBoundaryExpectation { + domain: "post", + kind: "1", + radroots_type: "RadrootsPost", + rpc_methods: &["events.post.publish", "events.post.list", "events.post.get"], + witnesses: &POST_WITNESSES, + }, + EventBoundaryExpectation { + domain: "comment", + kind: "1111", + radroots_type: "RadrootsComment", + rpc_methods: &[ + "events.comment.publish", + "events.comment.list", + "events.comment.get", + ], + witnesses: &COMMENT_WITNESSES, + }, + EventBoundaryExpectation { + domain: "reaction", + kind: "7", + radroots_type: "RadrootsReaction", + rpc_methods: &[ + "events.reaction.publish", + "events.reaction.list", + "events.reaction.get", + ], + witnesses: &REACTION_WITNESSES, + }, + EventBoundaryExpectation { + domain: "seal", + kind: "13", + radroots_type: "RadrootsSeal", + rpc_methods: &["events.seal.encode", "events.seal.decode"], + witnesses: &SEAL_WITNESSES, + }, + EventBoundaryExpectation { + domain: "message", + kind: "14", + radroots_type: "RadrootsMessage", + rpc_methods: &[ + "events.message.publish", + "events.message.list", + "events.message.get", + ], + witnesses: &MESSAGE_WITNESSES, + }, + EventBoundaryExpectation { + domain: "message_file", + kind: "15", + radroots_type: "RadrootsMessageFile", + rpc_methods: &[ + "events.message_file.publish", + "events.message_file.list", + "events.message_file.get", + ], + witnesses: &MESSAGE_FILE_WITNESSES, + }, + EventBoundaryExpectation { + domain: "gift_wrap", + kind: "1059", + radroots_type: "RadrootsGiftWrap", + rpc_methods: &[ + "events.gift_wrap.publish", + "events.gift_wrap.list", + "events.gift_wrap.get", + ], + witnesses: &GIFT_WRAP_WITNESSES, + }, + EventBoundaryExpectation { + domain: "list", + kind: "10000..10102", + radroots_type: "RadrootsList", + rpc_methods: &["events.list.publish", "events.list.list", "events.list.get"], + witnesses: &LIST_WITNESSES, + }, + EventBoundaryExpectation { + domain: "list_set", + kind: "30000..39092", + radroots_type: "RadrootsListSet", + rpc_methods: &[ + "events.list_set.publish", + "events.list_set.list", + "events.list_set.get", + ], + witnesses: &LIST_SET_WITNESSES, + }, + EventBoundaryExpectation { + domain: "app_data", + kind: "30078", + radroots_type: "RadrootsAppData", + rpc_methods: &[ + "events.app_data.publish", + "events.app_data.list", + "events.app_data.get", + ], + witnesses: &APP_DATA_WITNESSES, + }, + EventBoundaryExpectation { + domain: "app_handler", + kind: "31990", + radroots_type: "KIND_APPLICATION_HANDLER", + rpc_methods: &[ + "events.app_handler.publish", + "events.app_handler.list", + "events.app_handler.get", + ], + witnesses: &APP_HANDLER_WITNESSES, + }, + EventBoundaryExpectation { + domain: "farm", + kind: "30340", + radroots_type: "RadrootsFarm", + rpc_methods: &["events.farm.publish", "events.farm.list", "events.farm.get"], + witnesses: &FARM_WITNESSES, + }, + EventBoundaryExpectation { + domain: "plot", + kind: "30350", + radroots_type: "RadrootsPlot", + rpc_methods: &["events.plot.publish", "events.plot.list", "events.plot.get"], + witnesses: &PLOT_WITNESSES, + }, + EventBoundaryExpectation { + domain: "coop", + kind: "30360", + radroots_type: "RadrootsCoop", + rpc_methods: &["events.coop.publish", "events.coop.list", "events.coop.get"], + witnesses: &COOP_WITNESSES, + }, + EventBoundaryExpectation { + domain: "document", + kind: "30361", + radroots_type: "RadrootsDocument", + rpc_methods: &[ + "events.document.publish", + "events.document.list", + "events.document.get", + ], + witnesses: &DOCUMENT_WITNESSES, + }, + EventBoundaryExpectation { + domain: "resource_area", + kind: "30370", + radroots_type: "RadrootsResourceArea", + rpc_methods: &[ + "events.resource_area.publish", + "events.resource_area.list", + "events.resource_area.get", + ], + witnesses: &RESOURCE_AREA_WITNESSES, + }, + EventBoundaryExpectation { + domain: "resource_cap", + kind: "30371", + radroots_type: "RadrootsResourceHarvestCap", + rpc_methods: &[ + "events.resource_cap.publish", + "events.resource_cap.list", + "events.resource_cap.get", + ], + witnesses: &RESOURCE_CAP_WITNESSES, + }, + EventBoundaryExpectation { + domain: "listing", + kind: "30402", + radroots_type: "RadrootsListing", + rpc_methods: &[ + "events.listing.publish", + "events.listing.list", + "events.listing.get", + ], + witnesses: &LISTING_WITNESSES, + }, + EventBoundaryExpectation { + domain: "listing_draft", + kind: "30403", + radroots_type: "RadrootsListing", + rpc_methods: &[ + "events.listing_draft.publish", + "events.listing_draft.list", + "events.listing_draft.get", + ], + witnesses: &LISTING_DRAFT_WITNESSES, + }, + EventBoundaryExpectation { + domain: "dvm_request", + kind: "5000-5999", + radroots_type: "RadrootsJobRequest", + rpc_methods: &[ + "events.dvm_request.publish", + "events.dvm_request.list", + "events.dvm_request.get", + ], + witnesses: &DVM_REQUEST_WITNESSES, + }, + EventBoundaryExpectation { + domain: "dvm_result", + kind: "6000-6999", + radroots_type: "RadrootsJobResult", + rpc_methods: &[ + "events.dvm_result.publish", + "events.dvm_result.list", + "events.dvm_result.get", + ], + witnesses: &DVM_RESULT_WITNESSES, + }, + EventBoundaryExpectation { + domain: "dvm_feedback", + kind: "7000", + radroots_type: "RadrootsJobFeedback", + rpc_methods: &[ + "events.dvm_feedback.publish", + "events.dvm_feedback.list", + "events.dvm_feedback.get", + ], + witnesses: &DVM_FEEDBACK_WITNESSES, + }, + EventBoundaryExpectation { + domain: "trade:listing", + kind: "5321/6321/5322/6322", + radroots_type: "TradeListingEnvelope", + rpc_methods: &[ + "domains.trade.listing.validate", + "domains.trade.listing.order", + "domains.trade.listing.series.get", + "domains.trade.listing.dvm.list", + ], + witnesses: &TRADE_LISTING_WITNESSES, + }, + EventBoundaryExpectation { + domain: "relay_doc", + kind: "N/A", + radroots_type: "RadrootsRelayDocument", + rpc_methods: &["system.relay_doc.get"], + witnesses: &RELAY_DOC_WITNESSES, + }, +]; + #[derive(Debug, Deserialize)] struct ReleaseContractFile { release: ReleaseSection, @@ -343,6 +979,214 @@ fn parse_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> { } } +fn resolve_event_boundary_matrix_path_with_override( + workspace_root: &Path, + event_boundary_override: Option<PathBuf>, +) -> Result<PathBuf, String> { + if let Some(path) = event_boundary_override { + if !path.is_file() { + return Err(format!( + "{EVENT_BOUNDARY_MATRIX_ENV} points to a missing canonical event matrix file: {}", + path.display() + )); + } + return Ok(path); + } + + for ancestor in workspace_root.ancestors() { + for relative in EVENT_BOUNDARY_MATRIX_RELATIVES { + let candidate = ancestor.join(relative); + if candidate.is_file() { + return Ok(candidate); + } + } + } + + Err(format!( + "canonical event matrix not found; set {EVENT_BOUNDARY_MATRIX_ENV} or provide one of: {}", + EVENT_BOUNDARY_MATRIX_RELATIVES.join(", ") + )) +} + +fn parse_event_boundary_matrix(path: &Path) -> Result<BTreeMap<String, EventBoundaryRow>, String> { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(e) => return Err(format!("read {}: {e}", path.display())), + }; + let mut rows = BTreeMap::new(); + let mut in_table = false; + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed == "| Domain | Kind | Radroots Type | RPC Methods | Notes |" { + in_table = true; + continue; + } + if !in_table { + continue; + } + if trimmed.is_empty() { + break; + } + if trimmed == "| --- | --- | --- | --- | --- |" { + continue; + } + if !trimmed.starts_with('|') { + break; + } + let columns = trimmed + .trim_matches('|') + .split('|') + .map(|part| part.trim()) + .collect::<Vec<_>>(); + if columns.len() != 5 { + return Err(format!( + "canonical event matrix row in {} must have exactly 5 columns: {}", + path.display(), + trimmed + )); + } + let domain = columns[0].to_string(); + if domain.is_empty() { + return Err(format!( + "canonical event matrix row in {} must define a non-empty domain", + path.display() + )); + } + let rpc_methods = columns[3] + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| item.to_string()) + .collect::<BTreeSet<_>>(); + if rpc_methods.is_empty() { + return Err(format!( + "canonical event matrix row {} in {} must define rpc methods", + domain, + path.display() + )); + } + let row = EventBoundaryRow { + domain: domain.clone(), + kind: columns[1].to_string(), + radroots_type: columns[2].to_string(), + rpc_methods, + }; + if rows.insert(domain.clone(), row).is_some() { + return Err(format!( + "canonical event matrix {} has duplicate domain row {}", + path.display(), + domain + )); + } + } + + if rows.is_empty() { + return Err(format!( + "canonical event matrix {} does not contain the coverage table", + path.display() + )); + } + + Ok(rows) +} + +fn validate_event_boundary_source_witness( + workspace_root: &Path, + domain: &str, + witness: &EventBoundarySourceWitness, +) -> Result<(), String> { + let path = workspace_root.join(witness.relative_path); + let source = match fs::read_to_string(&path) { + Ok(source) => source, + Err(e) => return Err(format!("read {}: {e}", path.display())), + }; + for fragment in witness.required_fragments { + if !source.contains(fragment) { + return Err(format!( + "canonical event row {} is missing required implementation fragment {} in {}", + domain, + fragment, + path.display() + )); + } + } + Ok(()) +} + +fn validate_canonical_event_boundary_with_override( + workspace_root: &Path, + event_boundary_override: Option<PathBuf>, +) -> Result<(), String> { + let matrix_path = + resolve_event_boundary_matrix_path_with_override(workspace_root, event_boundary_override)?; + let rows = parse_event_boundary_matrix(&matrix_path)?; + let expected_domains = CANONICAL_EVENT_BOUNDARY_EXPECTATIONS + .iter() + .map(|row| row.domain.to_string()) + .collect::<BTreeSet<_>>(); + let actual_domains = rows.keys().cloned().collect::<BTreeSet<_>>(); + if actual_domains != expected_domains { + let missing = expected_domains + .difference(&actual_domains) + .cloned() + .collect::<BTreeSet<_>>(); + let extra = actual_domains + .difference(&expected_domains) + .cloned() + .collect::<BTreeSet<_>>(); + return Err(format!( + "canonical event matrix {} is missing rows: {}; and includes unexpected rows: {}", + matrix_path.display(), + join_set(&missing), + join_set(&extra) + )); + } + + for expectation in CANONICAL_EVENT_BOUNDARY_EXPECTATIONS { + let row = rows.get(expectation.domain).ok_or_else(|| { + format!( + "canonical event matrix {} is missing required row {}", + matrix_path.display(), + expectation.domain + ) + })?; + if row.kind != expectation.kind { + return Err(format!( + "canonical event row {} kind drift: expected {}, got {}", + expectation.domain, expectation.kind, row.kind + )); + } + if row.radroots_type != expectation.radroots_type { + return Err(format!( + "canonical event row {} type drift: expected {}, got {}", + expectation.domain, expectation.radroots_type, row.radroots_type + )); + } + let expected_methods = expectation + .rpc_methods + .iter() + .map(|method| (*method).to_string()) + .collect::<BTreeSet<_>>(); + if row.rpc_methods != expected_methods { + return Err(format!( + "canonical event row {} rpc drift: expected {}, got {}", + expectation.domain, + join_set(&expected_methods), + join_set(&row.rpc_methods) + )); + } + for witness in expectation.witnesses { + validate_event_boundary_source_witness(workspace_root, expectation.domain, witness)?; + } + } + + Ok(()) +} + +pub fn validate_canonical_event_boundary(workspace_root: &Path) -> Result<(), String> { + validate_canonical_event_boundary_with_override(workspace_root, None) +} + fn contract_root(workspace_root: &Path) -> PathBuf { workspace_root.join("spec") } @@ -2651,6 +3495,34 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] } #[test] + fn validate_current_canonical_event_boundary() { + let root = workspace_root(); + validate_canonical_event_boundary(&root).expect("validate canonical event boundary"); + } + + #[test] + fn canonical_event_boundary_reports_row_drift() { + let root = workspace_root(); + let matrix_path = + resolve_event_boundary_matrix_path_with_override(&root, None).expect("matrix path"); + let raw = fs::read_to_string(&matrix_path).expect("read matrix"); + let drifted = raw.replacen( + "| message | 14 | RadrootsMessage |", + "| message | 999 | RadrootsMessage |", + 1, + ); + let temp = temp_root("event_boundary_drift"); + let override_path = temp.join("spec-coverage.md"); + write_file(&override_path, &drifted); + + let err = validate_canonical_event_boundary_with_override(&root, Some(override_path)) + .expect_err("message kind drift should fail"); + assert!(err.contains("message kind drift")); + + let _ = fs::remove_dir_all(temp); + } + + #[test] fn validate_synthetic_operation_contract_bundle() { let root = create_synthetic_workspace("operation_contract_bundle"); add_operation_contract_files(&root); diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs @@ -47,6 +47,7 @@ fn validate_contract() -> Result<(), String> { let root = workspace_root(); contract::load_contract_bundle(&root) .and_then(|bundle| contract::validate_contract_bundle(&bundle)) + .and_then(|_| contract::validate_canonical_event_boundary(&root)) } fn release_preflight() -> Result<(), String> {