From 35dd5a3e6749c69d2849a7380f2af470131b0f12 Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Thu, 24 Sep 2026 09:03:27 -0600 Subject: [PATCH] feat: add nested path match predicates --- README.md | 12 + crates/rite-core/src/lib.rs | 296 +++++++++++++++++- crates/rite-server/src/lib.rs | 152 ++++++++- rite.example.toml | 6 + .../fixtures/inbox-notifications/v1/README.md | 8 +- 5 files changed, 462 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fdb7547..cdb02c4 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,20 @@ name = "real-prs-only" source = "github" match = { all_of = [{ event_type = "pull_request" }, { not = { draft = true } }] } action = { type = "http_post", url = "https://hooks.example.test/reviews" } + +[[rites]] +name = "inbox-attention" +source = "iris" +match = { all_of = [{ path_equals = { path = ["metadata", "message", "source"], value = "agent_hook" } }, { path_equals = { path = ["metadata", "message", "metadata", "schema_version"], value = 1 } }, { path_equals = { path = ["metadata", "message", "metadata", "installation_id"], value = "synthetic-alpha" } }, { path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "attention_required" } }, { path_equals = { path = ["metadata", "message", "metadata", "direction"], value = "inbound" } }, { path_equals = { path = ["metadata", "message", "metadata", "content_opt_in"], value = true } }] } +action = { type = "http_post", url = "https://hooks.example.test/inbox" } ``` +`path_equals` takes a literal array of object keys rooted at the normalized +`RiteEvent` JSON shape and a scalar string, integer, or boolean value. It never +splits keys on dots, traverses arrays, or infers a source/provider. A missing +path, `null`, or wrong JSON type is a non-match; malformed paths and +non-scalar expected values are rejected while loading configuration. + `all_of` and `any_of` take nonempty arrays of condition tables; `not` takes one condition table. A table mixing operators with other keys — or using two operators as siblings — is rejected at config load with an actionable error. diff --git a/crates/rite-core/src/lib.rs b/crates/rite-core/src/lib.rs index f68e27a..36bd683 100644 --- a/crates/rite-core/src/lib.rs +++ b/crates/rite-core/src/lib.rs @@ -100,7 +100,8 @@ pub struct RiteHandler { pub name: String, /// Source ID this handler accepts. pub source: String, - /// Fields to match against event attributes or metadata. + /// Fields to match against event attributes or metadata. The structured + /// `path_equals` predicate addresses literal nested object-key paths. /// /// Malformed compound forms (empty `all_of`/`any_of`, a `not` without /// exactly one child, operators mixed with leaves, or sibling @@ -150,8 +151,8 @@ impl RiteHandler { if operators.is_empty() { return Ok(MatchCondition::All( map.iter() - .map(|(k, v)| MatchCondition::Leaf(k.clone(), v.clone())) - .collect(), + .map(|(k, v)| Self::leaf_condition(k, v)) + .collect::>>()?, )); } if map.len() != operators.len() { @@ -179,6 +180,20 @@ impl RiteHandler { ("all_of" | "any_of", MatchValue::Array(_)) | ("not", MatchValue::Table(_)) | (_, MatchValue::String(_) | MatchValue::Integer(_) | MatchValue::Boolean(_)) => {} + ("path_equals", MatchValue::Table(_)) => { + Self::parse_path_equals(value)?; + } + ("path_equals", _) => { + return Err(RiteError::Config( + "match predicate 'path_equals' requires a table with a literal string-key path and scalar value" + .into(), + )); + } + (_, MatchValue::StringArray(_)) => { + return Err(RiteError::Config(format!( + "match key '{key}' requires a scalar string, integer, or boolean; string arrays are only valid as the path of 'path_equals'" + ))); + } ("all_of" | "any_of", MatchValue::Table(_)) => { return Err(RiteError::Config(format!( "match operator '{key}' requires an array of condition tables, not an inline table" @@ -205,6 +220,80 @@ impl RiteHandler { Ok(()) } + fn leaf_condition(key: &str, value: &MatchValue) -> Result { + if key == "path_equals" && matches!(value, MatchValue::Table(_)) { + let (path, expected) = Self::parse_path_equals(value)?; + return Ok(MatchCondition::PathEquals { path, expected }); + } + Ok(MatchCondition::Leaf(key.to_owned(), value.clone())) + } + + fn parse_path_equals(value: &MatchValue) -> Result<(Vec, MatchValue)> { + let MatchValue::Table(fields) = value else { + return Err(RiteError::Config( + "match predicate 'path_equals' requires a table with a literal string-key path and scalar value" + .into(), + )); + }; + if fields.len() != 2 { + let unknown = fields + .keys() + .filter(|key| *key != "path" && *key != "value") + .cloned() + .collect::>(); + if !unknown.is_empty() { + return Err(RiteError::Config(format!( + "match predicate 'path_equals' has unsupported field(s): {}", + unknown.join(", ") + ))); + } + return Err(RiteError::Config( + "match predicate 'path_equals' requires exactly 'path' and 'value' fields".into(), + )); + } + let Some(path) = fields.get("path") else { + return Err(RiteError::Config( + "match predicate 'path_equals' requires a 'path' field".into(), + )); + }; + let path = match path { + MatchValue::StringArray(path) => path, + MatchValue::Array(children) if children.is_empty() => { + return Err(RiteError::Config( + "match predicate 'path_equals.path' must contain at least one key".into(), + )); + } + _ => { + return Err(RiteError::Config( + "match predicate 'path_equals.path' must be an array of literal string keys" + .into(), + )); + } + }; + if path.is_empty() { + return Err(RiteError::Config( + "match predicate 'path_equals.path' must contain at least one key".into(), + )); + } + if path.iter().any(String::is_empty) { + return Err(RiteError::Config( + "match predicate 'path_equals.path' cannot contain empty key segments".into(), + )); + } + let Some(expected) = fields.get("value") else { + return Err(RiteError::Config( + "match predicate 'path_equals' requires a 'value' field".into(), + )); + }; + if !expected.is_scalar() { + return Err(RiteError::Config( + "match predicate 'path_equals.value' must be a scalar string, integer, or boolean" + .into(), + )); + } + Ok((path.clone(), expected.clone())) + } + fn children_of( map: &BTreeMap, op: &str, @@ -282,10 +371,11 @@ where /// A scalar value used to match an event field or metadata item. /// -/// The `Table` and `Array` variants only appear in their declared compound -/// positions: `Table` for `not`, `Array` for `all_of`/`any_of`. Legacy flat -/// matchers keep scalar shapes verbatim, and unsupported structured values -/// are rejected at deserialization time. +/// The `Table` and `Array` variants only appear in their declared compound or +/// predicate positions: `Table` for `not`/`path_equals`, `Array` for +/// `all_of`/`any_of`, and `StringArray` for `path_equals.path`. Legacy flat +/// matchers keep scalar shapes verbatim, and unsupported structured values are +/// rejected at deserialization time. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub enum MatchValue { @@ -299,6 +389,8 @@ pub enum MatchValue { Table(BTreeMap), /// Array of inline tables (children of `all_of`/`any_of`). Array(Vec>), + /// Array of literal string keys used by `path_equals.path`. + StringArray(Vec), } impl MatchValue { @@ -307,10 +399,14 @@ impl MatchValue { Self::String(expected) => value.as_str() == Some(expected), Self::Integer(expected) => value.as_i64() == Some(*expected), Self::Boolean(expected) => value.as_bool() == Some(*expected), - Self::Table(_) | Self::Array(_) => false, + Self::StringArray(_) | Self::Table(_) | Self::Array(_) => false, } } + fn is_scalar(&self) -> bool { + matches!(self, Self::String(_) | Self::Integer(_) | Self::Boolean(_)) + } + /// Whether this value has the TOML shape the operator `op` requires. fn is_operator_shaped(&self, op: &str) -> bool { match op { @@ -331,6 +427,13 @@ pub enum MatchCondition { Not(Box), /// A single field-or-metadata equality/substring leaf. Leaf(String, MatchValue), + /// Exact equality against a value reached through literal object keys. + PathEquals { + /// Literal keys traversed from the serialized [`RiteEvent`] root. + path: Vec, + /// Scalar value that must equal the value at `path`. + expected: MatchValue, + }, } impl MatchCondition { @@ -342,6 +445,7 @@ impl MatchCondition { Self::Any(children) => children.iter().any(|c| c.matches(event)), Self::Not(inner) => !inner.matches(event), Self::Leaf(field, expected) => leaf_matches(field, expected, event), + Self::PathEquals { path, expected } => path_equals_matches(path, expected, event), } } } @@ -368,6 +472,28 @@ fn leaf_matches(field: &str, expected: &MatchValue, event: &RiteEvent) -> bool { } } +/// Evaluate a literal-key path predicate against the serialized event root. +/// +/// A missing path, `null`, an intermediate scalar/array, or a mismatched JSON +/// type is simply a non-match. Path segments are never split on punctuation, +/// so keys containing dots and Unicode remain addressable exactly as supplied. +fn path_equals_matches(path: &[String], expected: &MatchValue, event: &RiteEvent) -> bool { + if path.is_empty() { + return false; + } + let Ok(root) = serde_json::to_value(event) else { + return false; + }; + let mut current = &root; + for segment in path { + let Some(next) = current.get(segment) else { + return false; + }; + current = next; + } + expected.matches_json(current) +} + impl RiteHandler { /// Returns whether this handler matches an event. /// @@ -519,6 +645,160 @@ action = { type = "http_post", url = "https://example.test/hook" }"#, assert!(handler.matches(&event)); } + #[test] + fn path_equals_matches_literal_nested_paths_and_exact_types() { + let handler: RiteHandler = toml::from_str( + r#"name = "attention" +source = "iris" +match = { all_of = [{ path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "attention_required" } }, { path_equals = { path = ["metadata", "message", "metadata", "schema_version"], value = 1 } }, { path_equals = { path = ["metadata", "message", "metadata", "content_opt_in"], value = true } }, { path_equals = { path = ["metadata", "message", "metadata", "literal.key"], value = "π" } }] } +action = { type = "http_post", url = "https://example.test/hook" }"#, + ) + .expect("path predicate handler parses"); + let matching = event( + "iris", + "text", + Severity::Info, + Some("same body"), + BTreeMap::from([( + "message".into(), + serde_json::json!({ + "metadata": { + "event_kind": "attention_required", + "schema_version": 1, + "content_opt_in": true, + "literal.key": "π" + } + }), + )]), + ); + assert!(handler.matches(&matching)); + + let mut wrong_provenance = matching.clone(); + wrong_provenance + .metadata + .get_mut("message") + .and_then(Value::as_object_mut) + .and_then(|message| message.get_mut("metadata")) + .and_then(Value::as_object_mut) + .expect("metadata object") + .insert("event_kind".into(), Value::String("turn_ended".into())); + assert!(!handler.matches(&wrong_provenance)); + + let mut wrong_type = matching.clone(); + wrong_type + .metadata + .get_mut("message") + .and_then(Value::as_object_mut) + .and_then(|message| message.get_mut("metadata")) + .and_then(Value::as_object_mut) + .expect("metadata object") + .insert("schema_version".into(), Value::String("1".into())); + assert!(!handler.matches(&wrong_type)); + + let mut missing = matching.clone(); + missing + .metadata + .get_mut("message") + .and_then(Value::as_object_mut) + .and_then(|message| message.get_mut("metadata")) + .and_then(Value::as_object_mut) + .expect("metadata object") + .remove("event_kind"); + assert!(!handler.matches(&missing)); + + let mut null_value = matching.clone(); + null_value + .metadata + .get_mut("message") + .and_then(Value::as_object_mut) + .and_then(|message| message.get_mut("metadata")) + .and_then(Value::as_object_mut) + .expect("metadata object") + .insert("event_kind".into(), Value::Null); + assert!(!handler.matches(&null_value)); + + let mut wrong_source = matching; + wrong_source.source = "github".into(); + assert!(!handler.matches(&wrong_source)); + } + + #[test] + fn path_equals_declaration_round_trips_through_generated_handler_shape() { + let handler: RiteHandler = toml::from_str( + r#"name = "attention" +source = "iris" +match = { path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "attention_required" } } +action = { type = "http_post", url = "https://example.test/hook" }"#, + ) + .expect("path predicate handler parses"); + let encoded = serde_json::to_value(&handler).expect("handler serializes"); + assert_eq!( + encoded["match"]["path_equals"]["path"], + serde_json::json!(["metadata", "message", "metadata", "event_kind"]) + ); + assert_eq!( + encoded["match"]["path_equals"]["value"], + "attention_required" + ); + let decoded: RiteHandler = serde_json::from_value(encoded).expect("handler round-trips"); + assert_eq!(decoded, handler); + } + + #[test] + fn path_equals_rejects_invalid_paths_and_expected_values_at_load() { + for (label, matcher, expected_error) in [ + ( + "empty path", + "match = { path_equals = { path = [], value = \"x\" } }", + "at least one key", + ), + ( + "empty segment", + "match = { path_equals = { path = [\"metadata\", \"\"], value = \"x\" } }", + "empty key segments", + ), + ( + "missing value", + "match = { path_equals = { path = [\"metadata\"] } }", + "exactly 'path' and 'value'", + ), + ( + "unsupported expected table", + "match = { path_equals = { path = [\"metadata\"], value = { nested = true } } }", + "scalar string, integer, or boolean", + ), + ( + "unsupported extra field", + "match = { path_equals = { path = [\"metadata\"], value = \"x\", wildcard = true } }", + "unsupported field", + ), + ] { + let text = format!("name = \"{label}\"\nsource = \"iris\"\n{matcher}\n{ACTION}"); + let error = toml::from_str::(&text) + .expect_err(&format!("{label} must be rejected at load")); + assert!( + error.to_string().contains(expected_error), + "{label} diagnostic should name the invalid path predicate: {error}" + ); + } + } + + #[test] + fn legacy_scalar_path_equals_key_remains_a_metadata_leaf() { + let handler: RiteHandler = toml::from_str(&format!( + "name = \"legacy-path\"\nsource = \"iris\"\nmatch = {{ path_equals = \"legacy\" }}\n{ACTION}" + )) + .expect("legacy scalar path_equals parses"); + let matching = event( + "iris", + "text", + Severity::Info, + None, + BTreeMap::from([("path_equals".into(), Value::String("legacy".into()))]), + ); + assert!(handler.matches(&matching)); + } + #[test] fn templates_render_event_and_nested_metadata_fields() { let event = RiteEvent { diff --git a/crates/rite-server/src/lib.rs b/crates/rite-server/src/lib.rs index 379fb44..0c33219 100644 --- a/crates/rite-server/src/lib.rs +++ b/crates/rite-server/src/lib.rs @@ -918,6 +918,149 @@ mod tests { assert_eq!(response.status(), StatusCode::ACCEPTED); } + #[tokio::test] + async fn generated_handler_views_preserve_path_equals_configuration() { + let config = load_config( + r#"[sources.iris] + enabled = true + base_url = "http://127.0.0.1:1" + + [[rites]] + name = "attention" + source = "iris" + match = { path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "attention_required" } } + action = { type = "http_post", url = "https://example.test/hook" }"#, + ) + .expect("config parses"); + let state = configured_state("secret", config).expect("state configures"); + + let listed = crate::dispatch::execute_operation( + &state, + "list_handlers", + crate::dispatch::OperationInput::default(), + ) + .await + .expect("list_handlers succeeds"); + assert_eq!( + listed[0]["match"]["path_equals"]["path"], + serde_json::json!(["metadata", "message", "metadata", "event_kind"]) + ); + assert_eq!( + listed[0]["match"]["path_equals"]["value"], + "attention_required" + ); + + let get = crate::dispatch::execute_operation( + &state, + "get_handler", + crate::dispatch::OperationInput { + path: BTreeMap::from([(String::from("handler_id"), String::from("attention"))]), + ..Default::default() + }, + ) + .await + .expect("get_handler succeeds"); + assert_eq!(get, listed[0]); + } + + #[tokio::test] + async fn configured_iris_dispatch_selects_literal_provenance_and_rejects_false_positives() { + let fixtures: serde_json::Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/inbox-notifications/v1/fixtures.json" + )) + .expect("fixture JSON parses"); + let mut messages = Vec::new(); + let mut expected_records = Vec::new(); + for section in ["positive_cases", "replay_cases", "negative_cases"] { + for case in fixtures[section] + .as_array() + .expect("fixture section is an array") + { + let message = case["iris_message"].clone(); + if case["expected_selection"].as_bool() == Some(true) { + let metadata = &message["metadata"]; + expected_records.push(format!( + "{}:{}:{}", + metadata["event_kind"], + metadata["installation_id"], + metadata["occurrence_id"] + )); + } + messages.push(message); + } + } + assert_eq!(expected_records.len(), 5, "fixture selection baseline"); + + let (action_url, mut captured, shutdown) = receiver().await; + let iris_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("Iris listener binds"); + let iris_address = iris_listener.local_addr().expect("Iris listener address"); + tokio::spawn(async move { + let (mut socket, _) = iris_listener.accept().await.expect("Iris client connects"); + let mut response = String::from( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n", + ); + for message in messages { + response.push_str("event: message\r\ndata: "); + response.push_str(&message.to_string()); + response.push_str("\r\n\r\n"); + } + socket + .write_all(response.as_bytes()) + .await + .expect("fixture events write"); + }); + + let config_text = r#"[sources.iris] + enabled = true + base_url = "__IRIS_URL__" + + [[rites]] + name = "inbox-notification" + source = "iris" + match = { all_of = [{ path_equals = { path = ["metadata", "message", "source"], value = "agent_hook" } }, { any_of = [{ path_equals = { path = ["metadata", "message", "metadata", "installation_id"], value = "synthetic-alpha" } }, { path_equals = { path = ["metadata", "message", "metadata", "installation_id"], value = "synthetic-beta" } }] }, { path_equals = { path = ["metadata", "message", "metadata", "schema_version"], value = 1 } }, { any_of = [{ path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "turn_ended" } }, { path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "attention_required" } }, { path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "execution_error" } }] }, { path_equals = { path = ["metadata", "message", "metadata", "direction"], value = "inbound" } }, { path_equals = { path = ["metadata", "message", "metadata", "content_opt_in"], value = true } }] } + action = { type = "http_post", url = "__ACTION_URL__" } + "# + .replace("__IRIS_URL__", &format!("http://{iris_address}")) + .replace("__ACTION_URL__", &action_url); + let config = load_config(&config_text).expect("path predicate config parses"); + let state = configured_state("secret", config).expect("state configures"); + start_iris_subscription(&state); + + let mut observed_records = Vec::new(); + for _ in 0..expected_records.len() { + let (_headers, body) = timeout(Duration::from_secs(2), captured.recv()) + .await + .expect("selected event reaches action") + .expect("action receiver remains open"); + let event: serde_json::Value = + serde_json::from_str(&body).expect("action receives normalized JSON"); + let message = &event["metadata"]["message"]; + observed_records.push(format!( + "{}:{}:{}", + message["metadata"]["event_kind"], + message["metadata"]["installation_id"], + message["metadata"]["occurrence_id"] + )); + } + assert_eq!(observed_records, expected_records); + assert!( + timeout(Duration::from_millis(100), captured.recv()) + .await + .is_err(), + "unselected provenance variants must not reach the action" + ); + assert_eq!( + state + .metrics + .events_matched + .load(std::sync::atomic::Ordering::Relaxed), + expected_records.len() as u64 + ); + shutdown.send(()).expect("action receiver shuts down"); + } + #[test] fn example_and_readme_configs_load_and_validate() { let readme = include_str!("../../../README.md"); @@ -929,7 +1072,14 @@ mod tests { .iter() .map(|handler| handler.name.as_str()) .collect::>(); - assert_eq!(example_names, ["github-pr-opened", "discord-urgent-alert"]); + assert_eq!( + example_names, + [ + "github-pr-opened", + "discord-urgent-alert", + "inbox-attention" + ] + ); assert!( validate(&example_config) .iter() diff --git a/rite.example.toml b/rite.example.toml index 7dc2b03..ec71601 100644 --- a/rite.example.toml +++ b/rite.example.toml @@ -19,3 +19,9 @@ name = "discord-urgent-alert" source = "iris" match = { provider = "telegram", body_contains = "URGENT" } action = { type = "http_post", url = "https://example.test/hooks/urgent", headers = { "content-type" = "application/json" }, body_template = "{\"content\":\"{{title}}: {{body}}\"}" } + +[[rites]] +name = "inbox-attention" +source = "iris" +match = { all_of = [{ path_equals = { path = ["metadata", "message", "source"], value = "agent_hook" } }, { path_equals = { path = ["metadata", "message", "metadata", "schema_version"], value = 1 } }, { path_equals = { path = ["metadata", "message", "metadata", "installation_id"], value = "synthetic-alpha" } }, { path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "attention_required" } }, { path_equals = { path = ["metadata", "message", "metadata", "direction"], value = "inbound" } }, { path_equals = { path = ["metadata", "message", "metadata", "content_opt_in"], value = true } }] } +action = { type = "http_post", url = "https://example.test/hooks/inbox" } diff --git a/tests/fixtures/inbox-notifications/v1/README.md b/tests/fixtures/inbox-notifications/v1/README.md index 4e0c67c..ec5bd57 100644 --- a/tests/fixtures/inbox-notifications/v1/README.md +++ b/tests/fixtures/inbox-notifications/v1/README.md @@ -11,9 +11,11 @@ the fixture omits that mechanically duplicated subtree. Tests must parse the Iris message through that real adapter, then compare semantic values at the literal paths in `docs/design/inbox-notifications.md`. -`expected_selection` is policy guidance for the future selector: it is not a -claim that the current flat matcher can traverse the paths yet. Negative cases -must fail selection before any handler action is planned. +`expected_selection` is policy guidance for the selector. The `path_equals` +predicate can now express these literal paths without dotted-name inference; +these fixtures still do not claim durable receipt deduplication, authorization, +or notification delivery. Negative cases must fail selection before any handler +action is planned. `expected_selection` describes only static policy eligibility. An exact replay can remain statically eligible while its `receipt_context` separately records