diff --git a/AGENTS.md b/AGENTS.md index 6eedc55..d8dad3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ attune/ ├── config.{development,test}.yaml # Environment configs ├── Makefile # Common dev tasks ├── crates/ # Rust services -│ ├── common/ # Shared library (models, db, repos, mq, config, error) +│ ├── common/ # Shared library (models, db, repos, mq, config, error, template_resolver) │ ├── api/ # REST API service (8080) │ ├── executor/ # Execution orchestration service │ ├── worker/ # Action execution service (multi-runtime) @@ -243,10 +243,27 @@ Enforcement created → Execution scheduled → Worker executes Action - **Available at**: `http://localhost:8080` (dev), `/api-spec/openapi.json` for spec ### Common Library (`crates/common`) -- **Modules**: `models`, `repositories`, `db`, `config`, `error`, `mq`, `crypto`, `utils`, `workflow`, `pack_registry` +- **Modules**: `models`, `repositories`, `db`, `config`, `error`, `mq`, `crypto`, `utils`, `workflow`, `pack_registry`, `template_resolver` - **Exports**: Commonly used types re-exported from `lib.rs` - **Repository Layer**: All DB access goes through repositories in `repositories/` - **Message Queue**: Abstractions in `mq/` for RabbitMQ communication +- **Template Resolver**: Resolves `{{ }}` template variables in rule `action_params` during enforcement creation. Re-exported from `attune_common::{TemplateContext, resolve_templates}`. + +### Template Variable Syntax +Rule `action_params` support Jinja2-style `{{ source.path }}` templates resolved at enforcement creation time: + +| Namespace | Example | Description | +|-----------|---------|-------------| +| `event.payload.*` | `{{ event.payload.service }}` | Event payload fields | +| `event.id` | `{{ event.id }}` | Event database ID | +| `event.trigger` | `{{ event.trigger }}` | Trigger ref that generated the event | +| `event.created` | `{{ event.created }}` | Event creation timestamp (RFC 3339) | +| `pack.config.*` | `{{ pack.config.api_token }}` | Pack configuration values | +| `system.*` | `{{ system.timestamp }}` | System variables (timestamp, rule info) | + +- **Implementation**: `crates/common/src/template_resolver.rs` (also re-exported from `attune_sensor::template_resolver`) +- **Integration**: `crates/executor/src/event_processor.rs` calls `resolve_templates()` in `create_enforcement()` +- **IMPORTANT**: The old `trigger.payload.*` syntax was renamed to `event.payload.*` — the payload data comes from the Event, not the Trigger ### Web UI (`web/`) - **Generated Client**: OpenAPI client auto-generated from API spec diff --git a/crates/cli/README.md b/crates/cli/README.md index 0eda2a2..d4609c8 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -234,7 +234,7 @@ attune rule create \ --pack core \ --trigger core.webhook \ --action core.notify \ - --criteria '{"trigger.payload.severity": "critical"}' + --criteria '{"event.payload.severity": "critical"}' ``` ### Delete Rule diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 7368632..874e47d 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -17,12 +17,14 @@ pub mod pack_registry; pub mod repositories; pub mod runtime_detection; pub mod schema; +pub mod template_resolver; pub mod test_executor; pub mod utils; pub mod workflow; // Re-export commonly used types pub use error::{Error, Result}; +pub use template_resolver::{resolve_templates, TemplateContext}; /// Library version pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/sensor/src/template_resolver.rs b/crates/common/src/template_resolver.rs similarity index 58% rename from crates/sensor/src/template_resolver.rs rename to crates/common/src/template_resolver.rs index 121d9a6..ae18473 100644 --- a/crates/sensor/src/template_resolver.rs +++ b/crates/common/src/template_resolver.rs @@ -1,27 +1,44 @@ //! Template Resolver //! //! Resolves template variables in rule action parameters using context from -//! trigger payloads, pack configuration, and system variables. +//! event payloads, pack configuration, and system variables. //! //! Supports template syntax: `{{ source.path.to.value }}` //! -//! Example: +//! ## Available Template Sources +//! +//! - `event.payload.*` — Fields from the event payload +//! - `event.id` — The event's database ID +//! - `event.trigger` — The trigger ref that generated the event +//! - `event.created` — The event's creation timestamp +//! - `pack.config.*` — Pack configuration values +//! - `system.*` — System-provided variables (timestamp, rule info, etc.) +//! +//! ## Example +//! //! ```rust //! use serde_json::json; -//! use attune_sensor::template_resolver::{TemplateContext, resolve_templates}; +//! use attune_common::template_resolver::{TemplateContext, resolve_templates}; +//! +//! let context = TemplateContext::new( +//! json!({"service": "api-gateway"}), +//! json!({}), +//! json!({}), +//! ) +//! .with_event_id(42) +//! .with_event_trigger("core.webhook") +//! .with_event_created("2026-02-05T10:00:00Z"); //! //! let params = json!({ -//! "message": "Error in {{ trigger.payload.service }}" +//! "message": "Error in {{ event.payload.service }}", +//! "trigger": "{{ event.trigger }}", +//! "event_id": "{{ event.id }}" //! }); //! -//! let context = TemplateContext { -//! trigger_payload: json!({"service": "api-gateway"}), -//! pack_config: json!({}), -//! system_vars: json!({}), -//! }; -//! //! let resolved = resolve_templates(¶ms, &context).unwrap(); //! assert_eq!(resolved["message"], "Error in api-gateway"); +//! assert_eq!(resolved["trigger"], "core.webhook"); +//! assert_eq!(resolved["event_id"], 42); //! ``` use anyhow::Result; @@ -30,33 +47,71 @@ use serde_json::Value as JsonValue; use std::sync::LazyLock; use tracing::{debug, warn}; -/// Template context containing all available data sources +/// Template context containing all available data sources for template resolution. +/// +/// The context is structured around three namespaces: +/// - `event` — Event data including payload, id, trigger ref, and created timestamp +/// - `pack.config` — Pack configuration values +/// - `system` — System-provided variables #[derive(Debug, Clone)] pub struct TemplateContext { - /// Event/trigger payload data - pub trigger_payload: JsonValue, - /// Pack configuration + /// Event data (payload, id, trigger, created) — accessed as `event.*` + pub event: JsonValue, + /// Pack configuration — accessed as `pack.config.*` pub pack_config: JsonValue, - /// System-provided variables + /// System-provided variables — accessed as `system.*` pub system_vars: JsonValue, } impl TemplateContext { - /// Create a new template context - pub fn new(trigger_payload: JsonValue, pack_config: JsonValue, system_vars: JsonValue) -> Self { + /// Create a new template context with an event payload. + /// + /// The payload is nested under `event.payload`. Use builder methods + /// to add event metadata (`with_event_id`, `with_event_trigger`, `with_event_created`). + pub fn new(event_payload: JsonValue, pack_config: JsonValue, system_vars: JsonValue) -> Self { + let event = serde_json::json!({ + "payload": event_payload, + }); Self { - trigger_payload, + event, pack_config, system_vars, } } - /// Get a value from the context using a dotted path + /// Set the event ID in the context (accessible as `{{ event.id }}`). + pub fn with_event_id(mut self, id: i64) -> Self { + if let Some(obj) = self.event.as_object_mut() { + obj.insert("id".to_string(), serde_json::json!(id)); + } + self + } + + /// Set the trigger ref in the context (accessible as `{{ event.trigger }}`). + pub fn with_event_trigger(mut self, trigger_ref: &str) -> Self { + if let Some(obj) = self.event.as_object_mut() { + obj.insert("trigger".to_string(), serde_json::json!(trigger_ref)); + } + self + } + + /// Set the event created timestamp in the context (accessible as `{{ event.created }}`). + pub fn with_event_created(mut self, created: &str) -> Self { + if let Some(obj) = self.event.as_object_mut() { + obj.insert("created".to_string(), serde_json::json!(created)); + } + self + } + + /// Get a value from the context using a dotted path. /// /// Supports paths like: - /// - `trigger.payload.field` - /// - `pack.config.setting` - /// - `system.timestamp` + /// - `event.payload.field` — event payload data + /// - `event.id` — event ID + /// - `event.trigger` — trigger ref + /// - `event.created` — creation timestamp + /// - `pack.config.setting` — pack configuration + /// - `system.timestamp` — system variables pub fn get_value(&self, path: &str) -> Option { let parts: Vec<&str> = path.split('.').collect(); @@ -64,18 +119,12 @@ impl TemplateContext { return None; } - // Determine the root source - let root = match parts[0] { - "trigger" => { - // trigger.payload.* paths - if parts.len() < 2 || parts[1] != "payload" { - warn!( - "Invalid trigger path: {}, expected 'trigger.payload.*'", - path - ); - return None; - } - &self.trigger_payload + // Determine the root source and how many path segments to skip + let (root, skip_count) = match parts[0] { + "event" => { + // event.* paths navigate directly into the event JSON object + // e.g. event.id, event.trigger, event.created, event.payload.field + (&self.event, 1) } "pack" => { // pack.config.* paths @@ -83,22 +132,15 @@ impl TemplateContext { warn!("Invalid pack path: {}, expected 'pack.config.*'", path); return None; } - &self.pack_config + (&self.pack_config, 2) } - "system" => &self.system_vars, + "system" => (&self.system_vars, 1), _ => { warn!("Unknown template source: {}", parts[0]); return None; } }; - // Navigate the path (skip the first 2 parts for trigger/pack, 1 for system) - let skip_count = match parts[0] { - "trigger" | "pack" => 2, - "system" => 1, - _ => return None, - }; - extract_nested_value(root, &parts[skip_count..]) } } @@ -108,7 +150,7 @@ static TEMPLATE_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"\{\{\s*([^}]+?)\s*\}\}").expect("Failed to compile template regex") }); -/// Resolve all template variables in a JSON value +/// Resolve all template variables in a JSON value. /// /// Recursively processes objects and arrays, replacing template strings /// with values from the context. @@ -132,7 +174,7 @@ pub fn resolve_templates(value: &JsonValue, context: &TemplateContext) -> Result } } -/// Resolve templates in a string value +/// Resolve templates in a string value. /// /// If the string contains a single template that matches the entire string, /// returns the value with its original type (preserving numbers, booleans, etc). @@ -192,7 +234,7 @@ fn resolve_string_template(s: &str, context: &TemplateContext) -> Result Option { if path.is_empty() { return Some(root.clone()); @@ -220,7 +262,7 @@ fn extract_nested_value(root: &JsonValue, path: &[&str]) -> Option { Some(current.clone()) } -/// Convert a JSON value to a string for interpolation +/// Convert a JSON value to a string for interpolation. fn value_to_string(value: &JsonValue) -> String { match value { JsonValue::String(s) => s.clone(), @@ -229,7 +271,7 @@ fn value_to_string(value: &JsonValue) -> String { JsonValue::Null => String::new(), JsonValue::Array(_) | JsonValue::Object(_) => { // For complex types, serialize as JSON - serde_json::to_string(value).unwrap_or_else(|_| String::new()) + serde_json::to_string(value).unwrap_or_default() } } } @@ -240,8 +282,8 @@ mod tests { use serde_json::json; fn create_test_context() -> TemplateContext { - TemplateContext { - trigger_payload: json!({ + TemplateContext::new( + json!({ "service": "api-gateway", "message": "Connection timeout", "severity": "critical", @@ -253,29 +295,29 @@ mod tests { }, "tags": ["production", "backend"] }), - pack_config: json!({ + json!({ "api_token": "secret123", "alert_channel": "#incidents", "timeout": 30 }), - system_vars: json!({ + json!({ "timestamp": "2026-01-17T15:30:00Z", "rule": { "id": 42, "ref": "test.rule" - }, - "event": { - "id": 123 } }), - } + ) + .with_event_id(123) + .with_event_trigger("core.error_event") + .with_event_created("2026-01-17T15:30:00Z") } #[test] fn test_simple_string_substitution() { let context = create_test_context(); let template = json!({ - "message": "Hello {{ trigger.payload.service }}" + "message": "Hello {{ event.payload.service }}" }); let result = resolve_templates(&template, &context).unwrap(); @@ -287,12 +329,12 @@ mod tests { let context = create_test_context(); // Number - let template = json!({"count": "{{ trigger.payload.count }}"}); + let template = json!({"count": "{{ event.payload.count }}"}); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["count"], 42); // Boolean - let template = json!({"enabled": "{{ trigger.payload.enabled }}"}); + let template = json!({"enabled": "{{ event.payload.enabled }}"}); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["enabled"], true); } @@ -301,8 +343,8 @@ mod tests { fn test_nested_object_access() { let context = create_test_context(); let template = json!({ - "host": "{{ trigger.payload.metadata.host }}", - "port": "{{ trigger.payload.metadata.port }}" + "host": "{{ event.payload.metadata.host }}", + "port": "{{ event.payload.metadata.port }}" }); let result = resolve_templates(&template, &context).unwrap(); @@ -314,8 +356,8 @@ mod tests { fn test_array_access() { let context = create_test_context(); let template = json!({ - "first_tag": "{{ trigger.payload.tags.0 }}", - "second_tag": "{{ trigger.payload.tags.1 }}" + "first_tag": "{{ event.payload.tags.0 }}", + "second_tag": "{{ event.payload.tags.1 }}" }); let result = resolve_templates(&template, &context).unwrap(); @@ -342,20 +384,65 @@ mod tests { let template = json!({ "timestamp": "{{ system.timestamp }}", "rule_id": "{{ system.rule.id }}", - "event_id": "{{ system.event.id }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["timestamp"], "2026-01-17T15:30:00Z"); assert_eq!(result["rule_id"], 42); + } + + #[test] + fn test_event_metadata_id() { + let context = create_test_context(); + let template = json!({ + "event_id": "{{ event.id }}" + }); + + let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["event_id"], 123); } + #[test] + fn test_event_metadata_trigger() { + let context = create_test_context(); + let template = json!({ + "trigger_ref": "{{ event.trigger }}" + }); + + let result = resolve_templates(&template, &context).unwrap(); + assert_eq!(result["trigger_ref"], "core.error_event"); + } + + #[test] + fn test_event_metadata_created() { + let context = create_test_context(); + let template = json!({ + "created_at": "{{ event.created }}" + }); + + let result = resolve_templates(&template, &context).unwrap(); + assert_eq!(result["created_at"], "2026-01-17T15:30:00Z"); + } + + #[test] + fn test_event_metadata_in_interpolation() { + let context = create_test_context(); + let template = json!({ + "summary": "Event {{ event.id }} from {{ event.trigger }} at {{ event.created }}" + }); + + let result = resolve_templates(&template, &context).unwrap(); + assert_eq!( + result["summary"], + "Event 123 from core.error_event at 2026-01-17T15:30:00Z" + ); + } + #[test] fn test_missing_value_returns_null() { let context = create_test_context(); let template = json!({ - "missing": "{{ trigger.payload.nonexistent }}" + "missing": "{{ event.payload.nonexistent }}" }); let result = resolve_templates(&template, &context).unwrap(); @@ -366,7 +453,7 @@ mod tests { fn test_multiple_templates_in_string() { let context = create_test_context(); let template = json!({ - "message": "Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}" + "message": "Error in {{ event.payload.service }}: {{ event.payload.message }}" }); let result = resolve_templates(&template, &context).unwrap(); @@ -396,11 +483,11 @@ mod tests { let context = create_test_context(); let template = json!({ "nested": { - "field1": "{{ trigger.payload.service }}", + "field1": "{{ event.payload.service }}", "field2": "{{ pack.config.timeout }}" }, "array": [ - "{{ trigger.payload.severity }}", + "{{ event.payload.severity }}", "static value" ] }); @@ -414,14 +501,10 @@ mod tests { #[test] fn test_empty_template_context() { - let context = TemplateContext { - trigger_payload: json!({}), - pack_config: json!({}), - system_vars: json!({}), - }; + let context = TemplateContext::new(json!({}), json!({}), json!({})); let template = json!({ - "message": "{{ trigger.payload.missing }}" + "message": "{{ event.payload.missing }}" }); let result = resolve_templates(&template, &context).unwrap(); @@ -432,7 +515,7 @@ mod tests { fn test_whitespace_in_templates() { let context = create_test_context(); let template = json!({ - "message": "{{ trigger.payload.service }}" + "message": "{{ event.payload.service }}" }); let result = resolve_templates(&template, &context).unwrap(); @@ -444,12 +527,14 @@ mod tests { let context = create_test_context(); let template = json!({ "channel": "{{ pack.config.alert_channel }}", - "message": "🚨 Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}", - "severity": "{{ trigger.payload.severity }}", + "message": "🚨 Error in {{ event.payload.service }}: {{ event.payload.message }}", + "severity": "{{ event.payload.severity }}", "details": { - "host": "{{ trigger.payload.metadata.host }}", - "count": "{{ trigger.payload.count }}", - "tags": "{{ trigger.payload.tags }}" + "host": "{{ event.payload.metadata.host }}", + "count": "{{ event.payload.count }}", + "tags": "{{ event.payload.tags }}", + "event_id": "{{ event.id }}", + "trigger": "{{ event.trigger }}" }, "timestamp": "{{ system.timestamp }}" }); @@ -463,6 +548,52 @@ mod tests { assert_eq!(result["severity"], "critical"); assert_eq!(result["details"]["host"], "web-01"); assert_eq!(result["details"]["count"], 42); + assert_eq!(result["details"]["event_id"], 123); + assert_eq!(result["details"]["trigger"], "core.error_event"); assert_eq!(result["timestamp"], "2026-01-17T15:30:00Z"); } + + #[test] + fn test_context_without_event_metadata() { + // Context with only a payload — no id, trigger, or created + let context = TemplateContext::new( + json!({"service": "test"}), + json!({}), + json!({}), + ); + + let template = json!({ + "service": "{{ event.payload.service }}", + "id": "{{ event.id }}", + "trigger": "{{ event.trigger }}" + }); + + let result = resolve_templates(&template, &context).unwrap(); + assert_eq!(result["service"], "test"); + // Missing metadata returns null + assert!(result["id"].is_null()); + assert!(result["trigger"].is_null()); + } + + #[test] + fn test_unknown_source() { + let context = create_test_context(); + let template = json!({ + "value": "{{ unknown.field }}" + }); + + let result = resolve_templates(&template, &context).unwrap(); + assert!(result["value"].is_null()); + } + + #[test] + fn test_invalid_pack_path() { + let context = create_test_context(); + let template = json!({ + "value": "{{ pack.invalid.field }}" + }); + + let result = resolve_templates(&template, &context).unwrap(); + assert!(result["value"].is_null()); + } } diff --git a/crates/executor/src/event_processor.rs b/crates/executor/src/event_processor.rs index c79d341..f236e65 100644 --- a/crates/executor/src/event_processor.rs +++ b/crates/executor/src/event_processor.rs @@ -20,6 +20,7 @@ use attune_common::{ rule::RuleRepository, Create, FindById, List, }, + template_resolver::{resolve_templates, TemplateContext}, }; /// Event processor that handles event-to-rule matching @@ -189,8 +190,8 @@ impl EventProcessor { .cloned() .unwrap_or_else(|| serde_json::Map::new()); - // Resolve action parameters (simplified - full template resolution would go here) - let resolved_params = Self::resolve_action_params(&rule.action_params, &payload)?; + // Resolve action parameters using the template resolver + let resolved_params = Self::resolve_action_params(rule, event, &payload)?; let create_input = CreateEnforcementInput { rule: Some(rule.id), @@ -240,10 +241,7 @@ impl EventProcessor { let payload = match &event.payload { Some(p) => p, None => { - debug!( - "Event {} has no payload, matching by default", - event.id - ); + debug!("Event {} has no payload, matching by default", event.id); return Ok(true); } }; @@ -351,14 +349,43 @@ impl EventProcessor { Ok(current) } - /// Resolve action parameters (simplified - full template resolution would go here) + /// Resolve action parameters by applying template variable substitution. + /// + /// Replaces `{{ event.payload.* }}`, `{{ event.id }}`, `{{ event.trigger }}`, + /// `{{ event.created }}`, `{{ pack.config.* }}`, and `{{ system.* }}` references + /// in the rule's `action_params` with values from the event and context. fn resolve_action_params( - action_params: &serde_json::Value, - _payload: &serde_json::Value, + rule: &Rule, + event: &Event, + event_payload: &serde_json::Value, ) -> Result> { - // For now, just convert to map if it's an object - // Full implementation would do template resolution - if let Some(obj) = action_params.as_object() { + let action_params = &rule.action_params; + + // If there are no action params, return empty + if action_params.is_null() || action_params.as_object().map_or(true, |o| o.is_empty()) { + return Ok(serde_json::Map::new()); + } + + // Build template context from the event + let context = TemplateContext::new( + event_payload.clone(), + // TODO: Load pack config from database for pack.config.* resolution + serde_json::json!({}), + serde_json::json!({ + "timestamp": chrono::Utc::now().to_rfc3339(), + "rule": { + "id": rule.id, + "ref": rule.r#ref, + }, + }), + ) + .with_event_id(event.id) + .with_event_trigger(&event.trigger_ref) + .with_event_created(&event.created.to_rfc3339()); + + let resolved = resolve_templates(action_params, &context)?; + + if let Some(obj) = resolved.as_object() { Ok(obj.clone()) } else { Ok(serde_json::Map::new()) diff --git a/crates/sensor/src/lib.rs b/crates/sensor/src/lib.rs index 67dec3f..1dbd5d3 100644 --- a/crates/sensor/src/lib.rs +++ b/crates/sensor/src/lib.rs @@ -8,7 +8,11 @@ pub mod rule_lifecycle_listener; pub mod sensor_manager; pub mod sensor_worker_registration; pub mod service; -pub mod template_resolver; + +// Re-export template resolver from common crate +pub mod template_resolver { + pub use attune_common::template_resolver::*; +} // Re-export commonly used types pub use rule_lifecycle_listener::RuleLifecycleListener; diff --git a/docs/api/api-rules.md b/docs/api/api-rules.md index 2d4effd..3b982c9 100644 --- a/docs/api/api-rules.md +++ b/docs/api/api-rules.md @@ -32,8 +32,8 @@ Rules are the core automation logic in Attune that connect triggers to actions. }, "action_params": { "channel": "#alerts", - "message": "Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}", - "severity": "{{ trigger.payload.severity }}" + "message": "Error in {{ event.payload.service }}: {{ event.payload.message }}", + "severity": "{{ event.payload.severity }}" }, "enabled": true, "created": "2024-01-13T10:00:00Z", @@ -64,7 +64,7 @@ Rules are the core automation logic in Attune that connect triggers to actions. The `action_params` field supports both static values and dynamic templates: - **Static values**: `"channel": "#alerts"` -- **Dynamic from trigger payload**: `"message": "{{ trigger.payload.message }}"` +- **Dynamic from event payload**: `"message": "{{ event.payload.message }}"` - **Dynamic from pack config**: `"token": "{{ pack.config.api_token }}"` - **System variables**: `"timestamp": "{{ system.timestamp }}"` @@ -295,8 +295,8 @@ Create a new rule in the system. }, "action_params": { "channel": "#alerts", - "message": "Error detected: {{ trigger.payload.message }}", - "severity": "{{ trigger.payload.severity }}" + "message": "Error detected: {{ event.payload.message }}", + "severity": "{{ event.payload.severity }}" }, "enabled": true } @@ -314,7 +314,7 @@ Create a new rule in the system. - `conditions`: JSON Logic conditions for rule evaluation (default: `{}`) - `action_params`: Parameters to pass to the action (default: `{}`) - Supports static values: `"channel": "#alerts"` - - Supports dynamic templates: `"message": "{{ trigger.payload.message }}"` + - Supports dynamic templates: `"message": "{{ event.payload.message }}"` - Supports pack config: `"token": "{{ pack.config.api_token }}"` - `enabled`: Whether the rule is active (default: `true`) @@ -341,8 +341,8 @@ Create a new rule in the system. }, "action_params": { "channel": "#alerts", - "message": "Error detected: {{ trigger.payload.message }}", - "severity": "{{ trigger.payload.severity }}" + "message": "Error detected: {{ event.payload.message }}", + "severity": "{{ event.payload.severity }}" }, "enabled": true, "created": "2024-01-13T10:00:00Z", @@ -384,7 +384,7 @@ All fields are optional. Only provided fields will be updated. }, "action_params": { "channel": "#critical-alerts", - "message": "CRITICAL: {{ trigger.payload.service }} - {{ trigger.payload.message }}", + "message": "CRITICAL: {{ event.payload.service }} - {{ event.payload.message }}", "priority": "high" }, "enabled": false @@ -416,7 +416,7 @@ All fields are optional. Only provided fields will be updated. }, "action_params": { "channel": "#critical-alerts", - "message": "CRITICAL: {{ trigger.payload.service }} - {{ trigger.payload.message }}", + "message": "CRITICAL: {{ event.payload.service }} - {{ event.payload.message }}", "priority": "high" }, "enabled": false, diff --git a/docs/cli/cli.md b/docs/cli/cli.md index 81b38da..cd91265 100644 --- a/docs/cli/cli.md +++ b/docs/cli/cli.md @@ -177,7 +177,7 @@ attune rule create \ --pack core \ --trigger core.webhook \ --action core.notify \ - --criteria '{"trigger.payload.severity": "critical"}' + --criteria '{"event.payload.severity": "critical"}' ``` #### Delete Rule diff --git a/docs/examples/rule-parameter-examples.md b/docs/examples/rule-parameter-examples.md index 16910f7..75400e9 100644 --- a/docs/examples/rule-parameter-examples.md +++ b/docs/examples/rule-parameter-examples.md @@ -35,7 +35,7 @@ This document provides practical, copy-paste ready examples of rule parameter ma --- -## Example 2: Dynamic from Trigger Payload +## Example 2: Dynamic from Event Payload **Use Case:** Alert with error details from event @@ -58,9 +58,9 @@ This document provides practical, copy-paste ready examples of rule parameter ma "action_ref": "slack.post_message", "action_params": { "channel": "#incidents", - "message": "🚨 Error in {{ trigger.payload.service }}: {{ trigger.payload.error }}", - "severity": "{{ trigger.payload.severity }}", - "timestamp": "{{ trigger.payload.timestamp }}" + "message": "🚨 Error in {{ event.payload.service }}: {{ event.payload.error }}", + "severity": "{{ event.payload.severity }}", + "timestamp": "{{ event.payload.timestamp }}" }, "enabled": true } @@ -163,8 +163,8 @@ This document provides practical, copy-paste ready examples of rule parameter ma "action_params": { "token": "{{ pack.config.token }}", "repo": "{{ pack.config.repo_owner }}/{{ pack.config.repo_name }}", - "title": "[{{ trigger.payload.severity }}] {{ trigger.payload.service }}: {{ trigger.payload.error_message }}", - "body": "Error Details:\n\nService: {{ trigger.payload.service }}\nSeverity: {{ trigger.payload.severity }}\n\nStack Trace:\n{{ trigger.payload.stack_trace }}", + "title": "[{{ event.payload.severity }}] {{ event.payload.service }}: {{ event.payload.error_message }}", + "body": "Error Details:\n\nService: {{ event.payload.service }}\nSeverity: {{ event.payload.severity }}\n\nStack Trace:\n{{ event.payload.stack_trace }}", "labels": ["bug", "automated"], "assignees": ["oncall"] }, @@ -219,12 +219,12 @@ This document provides practical, copy-paste ready examples of rule parameter ma "trigger_ref": "core.user_event", "action_ref": "audit.log", "action_params": { - "user_id": "{{ trigger.payload.user.id }}", - "user_name": "{{ trigger.payload.user.profile.name }}", - "user_email": "{{ trigger.payload.user.profile.email }}", - "department": "{{ trigger.payload.user.profile.department }}", - "action": "{{ trigger.payload.action }}", - "ip_address": "{{ trigger.payload.metadata.ip }}" + "user_id": "{{ event.payload.user.id }}", + "user_name": "{{ event.payload.user.profile.name }}", + "user_email": "{{ event.payload.user.profile.email }}", + "department": "{{ event.payload.user.profile.department }}", + "action": "{{ event.payload.action }}", + "ip_address": "{{ event.payload.metadata.ip }}" }, "enabled": true } @@ -271,10 +271,10 @@ This document provides practical, copy-paste ready examples of rule parameter ma "action_ref": "slack.post_message", "action_params": { "channel": "#alerts", - "message": "Primary error: {{ trigger.payload.errors.0 }}", - "secondary_error": "{{ trigger.payload.errors.1 }}", - "environment": "{{ trigger.payload.tags.0 }}", - "severity": "{{ trigger.payload.tags.1 }}" + "message": "Primary error: {{ event.payload.errors.0 }}", + "secondary_error": "{{ event.payload.errors.1 }}", + "environment": "{{ event.payload.tags.0 }}", + "severity": "{{ event.payload.tags.1 }}" }, "enabled": true } @@ -377,17 +377,17 @@ This document provides practical, copy-paste ready examples of rule parameter ma "routing_key": "{{ pack.config.routing_key }}", "event_action": "trigger", "payload": { - "summary": "{{ trigger.payload.metric_name }} exceeded threshold on {{ trigger.payload.host }}", + "summary": "{{ event.payload.metric_name }} exceeded threshold on {{ event.payload.host }}", "severity": "critical", - "source": "{{ trigger.payload.host }}", + "source": "{{ event.payload.host }}", "custom_details": { - "metric": "{{ trigger.payload.metric_name }}", - "current_value": "{{ trigger.payload.current_value }}", - "threshold": "{{ trigger.payload.threshold }}", - "duration": "{{ trigger.payload.duration_seconds }}s" + "metric": "{{ event.payload.metric_name }}", + "current_value": "{{ event.payload.current_value }}", + "threshold": "{{ event.payload.threshold }}", + "duration": "{{ event.payload.duration_seconds }}s" } }, - "dedup_key": "{{ trigger.payload.host }}_{{ trigger.payload.metric_name }}" + "dedup_key": "{{ event.payload.host }}_{{ event.payload.metric_name }}" }, "enabled": true } @@ -463,27 +463,27 @@ This document provides practical, copy-paste ready examples of rule parameter ma "fields": [ { "title": "Service", - "value": "{{ trigger.payload.service }}", + "value": "{{ event.payload.service }}", "short": true }, { "title": "Version", - "value": "{{ trigger.payload.version }}", + "value": "{{ event.payload.version }}", "short": true }, { "title": "Environment", - "value": "{{ trigger.payload.environment }}", + "value": "{{ event.payload.environment }}", "short": true }, { "title": "Deployed By", - "value": "{{ trigger.payload.deployed_by }}", + "value": "{{ event.payload.deployed_by }}", "short": true } ], "footer": "Attune Automation", - "ts": "{{ trigger.payload.timestamp }}" + "ts": "{{ event.payload.timestamp }}" } ] }, @@ -515,9 +515,9 @@ This document provides practical, copy-paste ready examples of rule parameter ma "trigger_ref": "core.alert_event", "action_ref": "slack.post_message", "action_params": { - "channel": "{{ trigger.payload.severity | default: 'info' | map: {'critical': '#incidents', 'high': '#alerts', 'medium': '#monitoring', 'low': '#logs'} }}", - "message": "{{ trigger.payload.message }}", - "color": "{{ trigger.payload.severity | map: {'critical': 'danger', 'high': 'warning', 'medium': 'good', 'low': '#cccccc'} }}" + "channel": "{{ event.payload.severity | default: 'info' | map: {'critical': '#incidents', 'high': '#alerts', 'medium': '#monitoring', 'low': '#logs'} }}", + "message": "{{ event.payload.message }}", + "color": "{{ event.payload.severity | map: {'critical': 'danger', 'high': 'warning', 'medium': 'good', 'low': '#cccccc'} }}" }, "enabled": true } @@ -584,12 +584,12 @@ The `config` field should contain the same resolved parameters. ```json { "action_params": { - "summary": "Error: {{ trigger.payload.message }}", + "summary": "Error: {{ event.payload.message }}", "details": { - "service": "{{ trigger.payload.service }}", - "host": "{{ trigger.payload.host }}", - "timestamp": "{{ trigger.payload.timestamp }}", - "stack_trace": "{{ trigger.payload.stack_trace }}" + "service": "{{ event.payload.service }}", + "host": "{{ event.payload.host }}", + "timestamp": "{{ event.payload.timestamp }}", + "stack_trace": "{{ event.payload.stack_trace }}" } } } @@ -600,11 +600,11 @@ The `config` field should contain the same resolved parameters. { "action_params": { "user": { - "id": "{{ trigger.payload.user.id }}", - "name": "{{ trigger.payload.user.name }}", - "email": "{{ trigger.payload.user.email }}" + "id": "{{ event.payload.user.id }}", + "name": "{{ event.payload.user.name }}", + "email": "{{ event.payload.user.email }}" }, - "action": "{{ trigger.payload.action_type }}" + "action": "{{ event.payload.action_type }}" } } ``` diff --git a/docs/guides/quickstart-example.md b/docs/guides/quickstart-example.md index 2f12e19..ee3bbee 100644 --- a/docs/guides/quickstart-example.md +++ b/docs/guides/quickstart-example.md @@ -226,7 +226,7 @@ Update the rule to use event data: ```sql UPDATE attune.rule -SET action_params = '{"message": "Timer fired at {{ trigger.payload.fired_at }}"}'::jsonb +SET action_params = '{"message": "Timer fired at {{ event.payload.fired_at }}"}'::jsonb WHERE ref = 'core.rule.timer_10s_echo'; ``` diff --git a/docs/workflows/dynamic-parameter-forms.md b/docs/workflows/dynamic-parameter-forms.md index e4ff1cb..59de62d 100644 --- a/docs/workflows/dynamic-parameter-forms.md +++ b/docs/workflows/dynamic-parameter-forms.md @@ -220,7 +220,7 @@ Potential improvements for the parameter form system: 1. **Advanced validation**: Support for min/max, pattern matching, custom validators 2. **Conditional fields**: Show/hide fields based on other field values 3. **Field hints**: Helper text, examples, tooltips -4. **Template variables**: Autocomplete for Jinja2 template syntax (e.g., `{{ trigger.payload.* }}`) +4. **Template variables**: Autocomplete for Jinja2 template syntax (e.g., `{{ event.payload.* }}`) 5. **Schema versioning**: Handle schema changes across pack versions 6. **Array item editing**: Better UX for editing array items individually 7. **Nested objects**: Support for deeply nested object schemas diff --git a/docs/workflows/parameter-mapping-status.md b/docs/workflows/parameter-mapping-status.md index 550a56b..1c113a4 100644 --- a/docs/workflows/parameter-mapping-status.md +++ b/docs/workflows/parameter-mapping-status.md @@ -2,22 +2,20 @@ ## Quick Reference -This document provides a quick overview of what exists and what needs to be implemented for rule parameter mapping. +This document tracks the implementation status of rule parameter mapping — the system that resolves `{{ }}` template variables in rule `action_params` before passing them to action executions. --- -## ✅ What Already Exists +## ✅ Completed ### Database Schema - **Migration:** `migrations/20240103000003_add_rule_action_params.sql` -- **Column:** `attune.rule.action_params` (JSONB, default `{}`) +- **Column:** `rule.action_params` (JSONB, default `{}`) - **Index:** `idx_rule_action_params_gin` (GIN index for efficient querying) -- **Status:** ✅ Complete ### Data Models - **File:** `crates/common/src/models.rs` - **Struct:** `rule::Rule` has `pub action_params: JsonValue` field -- **Status:** ✅ Complete ### API Layer - **File:** `crates/api/src/dto/rule.rs` @@ -26,232 +24,115 @@ This document provides a quick overview of what exists and what needs to be impl - `UpdateRuleRequest.action_params` (optional) - **Response DTOs:** - `RuleResponse.action_params` -- **Status:** ✅ Complete + - `RuleSummary.action_params` ### Repository Layer - **File:** `crates/common/src/repositories/rule.rs` -- **Operations:** - - `CreateRuleInput.action_params` included in INSERT - - `UpdateRuleInput.action_params` handled in UPDATE - - All SELECT queries include `action_params` column -- **Status:** ✅ Complete +- **Operations:** CREATE, UPDATE, and SELECT all handle `action_params` -### API Routes -- **File:** `crates/api/src/routes/rules.rs` -- **Handlers:** - - `create_rule()` accepts `action_params` from request - - `update_rule()` updates `action_params` if provided -- **Status:** ✅ Complete +### Template Resolver Module +- **File:** `crates/common/src/template_resolver.rs` +- **Struct:** `TemplateContext` with `event`, `pack_config`, and `system_vars` fields +- **Function:** `resolve_templates()` — recursively resolves `{{ }}` templates in JSON values +- **Re-exported** from `attune_common::template_resolver` and `attune_common::{TemplateContext, resolve_templates}` +- **Also re-exported** from `attune_sensor::template_resolver` for backward compatibility +- **20 unit tests** covering all template features -### Data Flow (Static Parameters) +### Template Syntax + +**Available Sources:** + +| Namespace | Example | Description | +|-----------|---------|-------------| +| `event.payload.*` | `{{ event.payload.service }}` | Event payload data | +| `event.id` | `{{ event.id }}` | Event database ID | +| `event.trigger` | `{{ event.trigger }}` | Trigger ref that generated the event | +| `event.created` | `{{ event.created }}` | Event creation timestamp (RFC 3339) | +| `pack.config.*` | `{{ pack.config.api_token }}` | Pack configuration values | +| `system.*` | `{{ system.timestamp }}` | System-provided variables | + +### Integration in Executor +- **File:** `crates/executor/src/event_processor.rs` +- **Method:** `resolve_action_params()` builds a `TemplateContext` from the event and rule, then calls `resolve_templates()` +- **Context includes:** + - `event.id`, `event.trigger`, `event.created`, `event.payload.*` from the `Event` model + - `system.timestamp` (current time), `system.rule.id`, `system.rule.ref` +- **Called during:** enforcement creation in `create_enforcement()` + +### Data Flow ``` -Rule.action_params (static JSON) - ↓ -Enforcement.config (copied verbatim) +Rule.action_params (templates) + ↓ resolve_templates() in EventProcessor +Enforcement.config (resolved values) ↓ Execution.config (passed through) ↓ Worker (receives as action parameters) ``` -- **Status:** ✅ Working for static values + +### Template Features +- ✅ Static values pass through unchanged +- ✅ Single-template type preservation (numbers, booleans, objects, arrays) +- ✅ String interpolation with multiple templates +- ✅ Nested object access via dot notation (`event.payload.metadata.host`) +- ✅ Array element access by index (`event.payload.tags.0`) +- ✅ Missing values resolve to `null` with warning logged +- ✅ Empty/null action_params handled gracefully + +### Documentation +- ✅ `docs/workflows/rule-parameter-mapping.md` — comprehensive user guide +- ✅ `docs/examples/rule-parameter-examples.md` — real-world examples +- ✅ `docs/api/api-rules.md` — API documentation +- ✅ Inline code documentation in `template_resolver.rs` --- -## ❌ What's Missing - -### Template Resolution Logic -- **Needed:** Parse and resolve `{{ }}` templates in `action_params` -- **Location:** `crates/sensor/src/` (new module needed) -- **Status:** ❌ Not implemented - -### Template Resolver Module -```rust -// NEW FILE: crates/sensor/src/template_resolver.rs -pub struct TemplateContext { - pub trigger_payload: JsonValue, - pub pack_config: JsonValue, - pub system_vars: JsonValue, -} - -pub fn resolve_templates( - params: &JsonValue, - context: &TemplateContext -) -> Result { - // Implementation needed -} -``` -- **Status:** ❌ Does not exist +## 🔄 Partially Implemented ### Pack Config Loading -- **Needed:** Load pack configuration from database -- **Current:** Rule matcher doesn't load pack config -- **Required for:** `{{ pack.config.* }}` templates -- **Status:** ❌ Not implemented - -### Integration in Rule Matcher -- **File:** `crates/sensor/src/rule_matcher.rs` -- **Method:** `create_enforcement()` -- **Current code (line 309):** -```rust -let config = Some(&rule.action_params); -``` -- **Needed code:** -```rust -// Load pack config -let pack_config = self.load_pack_config(&rule.pack_ref).await?; - -// Build template context -let context = TemplateContext { - trigger_payload: event.payload.clone().unwrap_or_default(), - pack_config, - system_vars: self.build_system_vars(rule, event), -}; - -// Resolve templates -let resolved_params = resolve_templates(&rule.action_params, &context)?; -let config = Some(resolved_params); -``` -- **Status:** ❌ Not implemented - -### Unit Tests -- **File:** `crates/sensor/src/template_resolver.rs` (tests module) -- **Needed tests:** - - Simple string substitution - - Nested object access - - Array element access - - Type preservation - - Missing value handling - - Pack config reference - - System variables - - Multiple templates in one string - - Invalid syntax handling -- **Status:** ❌ Not implemented - -### Integration Tests -- **Needed:** End-to-end test of template resolution -- **Scenario:** Create rule with templates → fire event → verify enforcement has resolved params -- **Status:** ❌ Not implemented +- **Current:** Executor passes empty `{}` for `pack.config` context +- **Needed:** Load pack configuration from database before template resolution +- **Impact:** `{{ pack.config.* }}` templates resolve to `null` until implemented +- **TODO comment** in `event_processor.rs` marks the location --- -## 📋 Implementation Checklist +## 📋 Remaining Work -### Phase 1: MVP (2-3 days) +### Phase 1: Complete Core (Short-term) -- [ ] **Create template resolver module** - - [ ] Define `TemplateContext` struct - - [ ] Implement `resolve_templates()` function - - [ ] Regex pattern matching for `{{ }}` - - [ ] JSON path extraction with dot notation - - [ ] Type preservation logic - - [ ] Error handling for missing values - - [ ] Unit tests (9+ test cases) +- [ ] **Pack config loading** — Load pack config from database for `{{ pack.config.* }}` resolution +- [ ] **Integration tests** — End-to-end test: create rule with templates → fire event → verify enforcement has resolved params -- [ ] **Add pack config loading** - - [ ] Add method to load pack config from database - - [ ] Implement in-memory cache with TTL - - [ ] Handle missing pack config gracefully +### Phase 2: Advanced Features (Future) -- [ ] **Integrate with rule matcher** - - [ ] Update `create_enforcement()` method - - [ ] Load pack config before resolution - - [ ] Build template context - - [ ] Call template resolver - - [ ] Handle resolution errors - - [ ] Log warnings for missing values - -- [ ] **System variables** - - [ ] Build system context (timestamp, rule ID, event ID) - - [ ] Document available system variables - -- [ ] **Testing** - - [ ] Unit tests for template resolver - - [ ] Integration test: end-to-end flow - - [ ] Test with missing values - - [ ] Test with nested objects - - [ ] Test with arrays - - [ ] Test performance (benchmark) - -- [ ] **Documentation** - - [x] User documentation (`docs/rule-parameter-mapping.md`) ✅ - - [x] API documentation updates (`docs/api-rules.md`) ✅ - - [ ] Code documentation (inline comments) - - [ ] Update sensor service docs - -### Phase 2: Advanced Features (1-2 days, future) - -- [ ] **Default values** - - [ ] Parse `| default: 'value'` syntax - - [ ] Apply defaults when value is null/missing - - [ ] Unit tests - -- [ ] **Filters** - - [ ] `upper` - Convert to uppercase - - [ ] `lower` - Convert to lowercase - - [ ] `trim` - Remove whitespace - - [ ] `date: ` - Format timestamp - - [ ] `truncate: ` - Truncate string - - [ ] `json` - Serialize to JSON string - - [ ] Unit tests for each filter - -- [ ] **Performance optimization** - - [ ] Cache compiled regex patterns - - [ ] Skip resolution if no `{{ }}` found - - [ ] Parallel template resolution - - [ ] Benchmark improvements +- [ ] **Default values** — Parse `| default: 'value'` syntax for fallback values +- [ ] **Filters** — `upper`, `lower`, `trim`, `date`, `truncate`, `json` +- [ ] **Conditional templates** — `{% if event.payload.severity == 'critical' %}...{% endif %}` +- [ ] **Performance** — Skip resolution early if no `{{ }}` patterns detected in action_params --- -## 🔍 Key Implementation Details +## 🔍 Template Example -### Current Enforcement Creation (line 306-348) - -```rust -async fn create_enforcement(&self, rule: &Rule, event: &Event) -> Result { - let payload = event.payload.clone().unwrap_or_default(); - let config = Some(&rule.action_params); // ← This line needs to change - - let enforcement_id = sqlx::query_scalar!( - r#" - INSERT INTO attune.enforcement - (rule, rule_ref, trigger_ref, config, event, status, payload, condition, conditions) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id - "#, - Some(rule.id), - &rule.r#ref, - &rule.trigger_ref, - config, // ← Resolved params go here - Some(event.id), - EnforcementStatus::Created as EnforcementStatus, - payload, - EnforcementCondition::All as EnforcementCondition, - &rule.conditions - ) - .fetch_one(&self.db) - .await?; - - // ... rest of method +**Input (Rule `action_params`):** +```json +{ + "message": "Error in {{ event.payload.service }}: {{ event.payload.message }}", + "channel": "{{ pack.config.alert_channel }}", + "severity": "{{ event.payload.severity }}", + "event_id": "{{ event.id }}", + "trigger": "{{ event.trigger }}" } ``` -### Template Examples - -**Input (Rule):** +**Context (built from Event + Rule):** ```json { - "action_params": { - "message": "Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}", - "channel": "{{ pack.config.alert_channel }}", - "severity": "{{ trigger.payload.severity }}" - } -} -``` - -**Context:** -```json -{ - "trigger": { + "event": { + "id": 456, + "trigger": "core.error_event", + "created": "2026-02-05T10:00:00Z", "payload": { "service": "api-gateway", "message": "Connection timeout", @@ -262,114 +143,30 @@ async fn create_enforcement(&self, rule: &Rule, event: &Event) -> Result { "config": { "alert_channel": "#incidents" } + }, + "system": { + "timestamp": "2026-02-05T10:00:01Z", + "rule": { "id": 42, "ref": "alerts.error_notification" } } } ``` -**Output (Enforcement):** +**Output (Enforcement `config`):** ```json { - "config": { - "message": "Error in api-gateway: Connection timeout", - "channel": "#incidents", - "severity": "critical" - } + "message": "Error in api-gateway: Connection timeout", + "channel": "#incidents", + "severity": "critical", + "event_id": 456, + "trigger": "core.error_event" } ``` --- -## 📊 Dependencies +## Related Documentation -### Existing (Already in Cargo.toml) -- `serde_json` - JSON manipulation ✅ -- `regex` - Pattern matching ✅ -- `anyhow` - Error handling ✅ -- `sqlx` - Database access ✅ - -### New Dependencies -- **None required** - Can implement with existing dependencies - ---- - -## 🎯 Success Criteria - -- [ ] Static parameters continue to work unchanged -- [ ] Can reference `{{ trigger.payload.* }}` fields -- [ ] Can reference `{{ pack.config.* }}` fields -- [ ] Can reference `{{ system.* }}` variables -- [ ] Type preservation (strings, numbers, booleans, objects, arrays) -- [ ] Nested object access with dot notation works -- [ ] Array element access by index works -- [ ] Missing values handled gracefully (null + warning) -- [ ] Invalid syntax handled gracefully (literal + error) -- [ ] Unit tests pass (90%+ coverage) -- [ ] Integration tests pass -- [ ] Documentation accurate and complete -- [ ] No performance regression (<500µs overhead) -- [ ] Backward compatibility maintained (100%) - ---- - -## 🚀 Getting Started - -1. **Read documentation:** - - `docs/rule-parameter-mapping.md` - User guide - - `work-summary/2026-01-17-parameter-templating.md` - Technical spec - -2. **Review current code:** - - `crates/sensor/src/rule_matcher.rs:306-348` - Where to integrate - - `crates/common/src/models.rs` - Rule model structure - - `migrations/20240103000003_add_rule_action_params.sql` - Schema - -3. **Start implementation:** - - Create `crates/sensor/src/template_resolver.rs` - - Write unit tests first (TDD approach) - - Implement template parsing and resolution - - Integrate with rule_matcher - - Run integration tests - -4. **Test thoroughly:** - - Unit tests for all edge cases - - Integration test with real database - - Manual testing with example rules - - Performance benchmarks - ---- - -## 📚 Related Documentation - -- [Rule Parameter Mapping Guide](./rule-parameter-mapping.md) - Complete user documentation -- [Rule Management API](./api-rules.md) - API reference with examples -- [Sensor Service Architecture](./sensor-service.md) - Service overview -- [Implementation Plan](../work-summary/2026-01-17-parameter-templating.md) - Technical specification -- [Session Summary](../work-summary/2026-01-17-session-parameter-mapping.md) - Discovery notes - ---- - -## 🏷️ Status Summary - -| Component | Status | Notes | -|-----------|--------|-------| -| Database schema | ✅ Complete | `action_params` column exists | -| Data models | ✅ Complete | Rule struct has field | -| API DTOs | ✅ Complete | Request/response support | -| API routes | ✅ Complete | CRUD operations work | -| Repository | ✅ Complete | All queries include field | -| Static parameters | ✅ Working | Flow end-to-end | -| Template resolver | ❌ Missing | Core implementation needed | -| Pack config loading | ❌ Missing | Required for `{{ pack.config }}` | -| Integration | ❌ Missing | Need to wire up resolver | -| Unit tests | ❌ Missing | Tests for resolver needed | -| Integration tests | ❌ Missing | E2E test needed | -| Documentation | ✅ Complete | User and tech docs done | - -**Overall Status:** 📝 Documented, ⏳ Implementation Pending - -**Priority:** P1 (High) - -**Estimated Effort:** 2-3 days (MVP), 1-2 days (advanced features) - -**Risk:** Low (backward compatible, well-scoped, clear requirements) - -**Value:** High (unlocks production use cases, user expectation) \ No newline at end of file +- [Rule Parameter Mapping Guide](./rule-parameter-mapping.md) +- [Rule Parameter Examples](../examples/rule-parameter-examples.md) +- [Rule Management API](../api/api-rules.md) +- [Executor Service Architecture](../architecture/executor-service.md) \ No newline at end of file diff --git a/docs/workflows/rule-parameter-mapping.md b/docs/workflows/rule-parameter-mapping.md index 70bbd7a..4738df9 100644 --- a/docs/workflows/rule-parameter-mapping.md +++ b/docs/workflows/rule-parameter-mapping.md @@ -5,7 +5,7 @@ Rules in Attune can specify parameters to pass to actions when triggered. These parameters can be: 1. **Static values** - Hard-coded values defined in the rule -2. **Dynamic from trigger payload** - Values extracted from the event that triggered the rule +2. **Dynamic from event payload** - Values extracted from the event that triggered the rule 3. **Dynamic from pack config** - Values from the pack's configuration This enables flexible parameter passing without hardcoding values or requiring custom code. @@ -27,7 +27,7 @@ Rule `action_params` uses a JSON object where each value can be: **Available Sources:** -- `trigger.payload.*` - Data from the event payload +- `event.payload.*` - Data from the event payload - `pack.config.*` - Configuration values from the pack - `system.*` - System-provided values (timestamp, execution context) @@ -55,13 +55,13 @@ When this rule triggers, the action receives exactly these parameters. --- -## Dynamic Parameters from Trigger Payload +## Dynamic Parameters from Event Payload Extract values from the event that triggered the rule. ### Example: Alert with Event Data -**Trigger Payload:** +**Event Payload:** ```json { "severity": "error", @@ -84,10 +84,10 @@ Extract values from the event that triggered the rule. "action_ref": "slack.post_message", "action_params": { "channel": "#incidents", - "message": "Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}", - "severity": "{{ trigger.payload.severity }}", - "host": "{{ trigger.payload.metadata.host }}", - "timestamp": "{{ trigger.payload.timestamp }}" + "message": "Error in {{ event.payload.service }}: {{ event.payload.message }}", + "severity": "{{ event.payload.severity }}", + "host": "{{ event.payload.metadata.host }}", + "timestamp": "{{ event.payload.timestamp }}" } } ``` @@ -135,7 +135,7 @@ Use configuration values stored at the pack level (useful for API keys, URLs, et "token": "{{ pack.config.api_token }}", "channel": "{{ pack.config.default_channel }}", "username": "{{ pack.config.bot_name }}", - "message": "{{ trigger.payload.message }}" + "message": "{{ event.payload.message }}" } } ``` @@ -160,8 +160,8 @@ Combine static and dynamic values in the same rule: "action_params": { "repo": "myorg/myrepo", "token": "{{ pack.config.github_token }}", - "title": "Error: {{ trigger.payload.message }}", - "body": "Service {{ trigger.payload.service }} reported an error at {{ trigger.payload.timestamp }}", + "title": "Error: {{ event.payload.message }}", + "body": "Service {{ event.payload.service }} reported an error at {{ event.payload.timestamp }}", "labels": ["bug", "automated"], "assignees": ["oncall"] } @@ -177,11 +177,11 @@ Access nested properties using dot notation: ```json { "action_params": { - "user_id": "{{ trigger.payload.user.id }}", - "user_name": "{{ trigger.payload.user.profile.name }}", + "user_id": "{{ event.payload.user.id }}", + "user_name": "{{ event.payload.user.profile.name }}", "metadata": { - "ip_address": "{{ trigger.payload.request.client_ip }}", - "user_agent": "{{ trigger.payload.request.headers.user_agent }}" + "ip_address": "{{ event.payload.request.client_ip }}", + "user_agent": "{{ event.payload.request.headers.user_agent }}" } } } @@ -196,8 +196,8 @@ Access array elements by index: ```json { "action_params": { - "first_error": "{{ trigger.payload.errors.0 }}", - "primary_tag": "{{ trigger.payload.tags.0 }}" + "first_error": "{{ event.payload.errors.0 }}", + "primary_tag": "{{ event.payload.tags.0 }}" } } ``` @@ -211,8 +211,8 @@ Provide default values when the referenced field doesn't exist: ```json { "action_params": { - "priority": "{{ trigger.payload.priority | default: 'medium' }}", - "assignee": "{{ trigger.payload.assignee | default: 'unassigned' }}" + "priority": "{{ event.payload.priority | default: 'medium' }}", + "assignee": "{{ event.payload.assignee | default: 'unassigned' }}" } } ``` @@ -226,10 +226,10 @@ Template values preserve their JSON types: ```json { "action_params": { - "count": "{{ trigger.payload.count }}", // Number: 42 - "enabled": "{{ trigger.payload.enabled }}", // Boolean: true - "tags": "{{ trigger.payload.tags }}", // Array: ["a", "b"] - "metadata": "{{ trigger.payload.metadata }}" // Object: {"key": "value"} + "count": "{{ event.payload.count }}", // Number: 42 + "enabled": "{{ event.payload.enabled }}", // Boolean: true + "tags": "{{ event.payload.tags }}", // Array: ["a", "b"] + "metadata": "{{ event.payload.metadata }}" // Object: {"key": "value"} } } ``` @@ -261,8 +261,8 @@ Embed multiple values in a single string: ```json { "action_params": { - "message": "User {{ trigger.payload.user_id }} performed {{ trigger.payload.action }} at {{ system.timestamp }}", - "subject": "[{{ trigger.payload.severity | upper }}] {{ trigger.payload.service }} Alert" + "message": "User {{ event.payload.user_id }} performed {{ event.payload.action }} at {{ system.timestamp }}", + "subject": "[{{ event.payload.severity | upper }}] {{ event.payload.service }} Alert" } } ``` @@ -276,10 +276,10 @@ Apply transformations to values: ```json { "action_params": { - "uppercase_name": "{{ trigger.payload.name | upper }}", - "lowercase_email": "{{ trigger.payload.email | lower }}", - "formatted_date": "{{ trigger.payload.timestamp | date: '%Y-%m-%d' }}", - "truncated": "{{ trigger.payload.message | truncate: 100 }}" + "uppercase_name": "{{ event.payload.name | upper }}", + "lowercase_email": "{{ event.payload.email | lower }}", + "formatted_date": "{{ event.payload.timestamp | date: '%Y-%m-%d' }}", + "truncated": "{{ event.payload.message | truncate: 100 }}" } } ``` @@ -310,19 +310,19 @@ Apply transformations to values: "action_params": { "channel": "{{ pack.config.alert_channel }}", "token": "{{ pack.config.slack_token }}", - "message": "⚠️ Alert from {{ trigger.payload.source }}: {{ trigger.payload.message }}", + "message": "⚠️ Alert from {{ event.payload.source }}: {{ event.payload.message }}", "attachments": [ { - "color": "{{ trigger.payload.severity | default: 'warning' }}", + "color": "{{ event.payload.severity | default: 'warning' }}", "fields": [ { "title": "Service", - "value": "{{ trigger.payload.service }}", + "value": "{{ event.payload.service }}", "short": true }, { "title": "Environment", - "value": "{{ trigger.payload.environment | default: 'production' }}", + "value": "{{ event.payload.environment | default: 'production' }}", "short": true } ], @@ -349,7 +349,7 @@ Apply transformations to values: "token": "{{ pack.config.jira_token }}" }, "issuetype": "Bug", - "summary": "[{{ trigger.payload.severity }}] {{ trigger.payload.service }}: {{ trigger.payload.message }}", + "summary": "[{{ event.payload.severity }}] {{ event.payload.service }}: {{ event.payload.message }}", "description": { "type": "doc", "content": [ @@ -358,14 +358,14 @@ Apply transformations to values: "content": [ { "type": "text", - "text": "Error Details:\n\nService: {{ trigger.payload.service }}\nHost: {{ trigger.payload.host }}\nTimestamp: {{ trigger.payload.timestamp }}\n\nStack Trace:\n{{ trigger.payload.stack_trace }}" + "text": "Error Details:\n\nService: {{ event.payload.service }}\nHost: {{ event.payload.host }}\nTimestamp: {{ event.payload.timestamp }}\n\nStack Trace:\n{{ event.payload.stack_trace }}" } ] } ] }, - "priority": "{{ trigger.payload.priority | default: 'Medium' }}", - "labels": ["automated", "{{ trigger.payload.service }}"] + "priority": "{{ event.payload.priority | default: 'Medium' }}", + "labels": ["automated", "{{ event.payload.service }}"] } } ``` @@ -382,17 +382,17 @@ Apply transformations to values: "routing_key": "{{ pack.config.pagerduty_routing_key }}", "event_action": "trigger", "payload": { - "summary": "{{ trigger.payload.metric_name }} exceeded threshold on {{ trigger.payload.host }}", + "summary": "{{ event.payload.metric_name }} exceeded threshold on {{ event.payload.host }}", "severity": "critical", - "source": "{{ trigger.payload.host }}", + "source": "{{ event.payload.host }}", "custom_details": { - "metric": "{{ trigger.payload.metric_name }}", - "current_value": "{{ trigger.payload.current_value }}", - "threshold": "{{ trigger.payload.threshold }}", - "duration": "{{ trigger.payload.duration_seconds }}s" + "metric": "{{ event.payload.metric_name }}", + "current_value": "{{ event.payload.current_value }}", + "threshold": "{{ event.payload.threshold }}", + "duration": "{{ event.payload.duration_seconds }}s" } }, - "dedup_key": "{{ trigger.payload.host }}_{{ trigger.payload.metric_name }}" + "dedup_key": "{{ event.payload.host }}_{{ event.payload.metric_name }}" } } ``` @@ -431,9 +431,12 @@ Apply transformations to values: 1. **Rule Evaluation** - When an event matches a rule 2. **Template Extraction** - Identify `{{ }}` patterns in `action_params` 3. **Context Building** - Assemble available data: - - `trigger.payload` - Event payload data + - `event.id` - Event database ID + - `event.trigger` - Trigger ref that generated the event + - `event.created` - Event creation timestamp + - `event.payload` - Event payload data - `pack.config` - Pack configuration - - `system.*` - System-provided values + - `system.*` - System-provided values (timestamp, rule info) 4. **Value Resolution** - Extract values from context using dot notation paths 5. **Type Conversion** - Preserve JSON types (string, number, boolean, object, array) 6. **Parameter Assembly** - Build final parameter object @@ -444,7 +447,7 @@ Apply transformations to values: **Missing Values:** - If a referenced value doesn't exist and no default is provided, use `null` -- Log warning: `"Template reference not found: trigger.payload.missing_field"` +- Log warning: `"Template variable not found: event.payload.missing_field"` **Invalid Syntax:** - If template syntax is invalid, log error and use the raw string @@ -506,8 +509,8 @@ Pack configuration should be stored securely and can include: ```json { "action_params": { - "priority": "{{ trigger.payload.priority | default: 'medium' }}", - "assignee": "{{ trigger.payload.assignee | default: 'unassigned' }}" + "priority": "{{ event.payload.priority | default: 'medium' }}", + "assignee": "{{ event.payload.assignee | default: 'unassigned' }}" } } ``` @@ -516,8 +519,8 @@ Pack configuration should be stored securely and can include: ```json { "action_params": { - "user_email": "{{ trigger.payload.user.email }}", - "user_id": "{{ trigger.payload.user.id }}" + "user_email": "{{ event.payload.user.email }}", + "user_id": "{{ event.payload.user.id }}" } } ``` @@ -528,7 +531,7 @@ If a value never changes, keep it static: { "action_params": { "service_name": "my-service", // Static - never changes - "error_code": "{{ trigger.payload.code }}" // Dynamic - from event + "error_code": "{{ event.payload.code }}" // Dynamic - from event } } ``` @@ -581,10 +584,12 @@ Look for the resolved parameters in the execution's `config` field: { "id": 1, "config": { - "message": "Test message", // Resolved from trigger.payload.message - "severity": "info", // Resolved from trigger.payload.severity - "user_id": 123, // Resolved from trigger.payload.user.id - "user_name": "Alice" // Resolved from trigger.payload.user.name + "message": "Test message", // Resolved from event.payload.message + "severity": "info", // Resolved from event.payload.severity + "user_id": 123, // Resolved from event.payload.user.id + "user_name": "Alice" // Resolved from event.payload.user.name + "event_id": 456, // Resolved from event.id + "trigger": "core.test_event" // Resolved from event.trigger } } ``` @@ -608,7 +613,7 @@ Look for the resolved parameters in the execution's `config` field: ```json { "action_params": { - "message": "Error: {{ trigger.payload.message }}" + "message": "Error: {{ event.payload.message }}" } } ``` @@ -679,15 +684,15 @@ Look for the resolved parameters in the execution's `config` field: ```json { "action_params": { - "channel": "{% if trigger.payload.severity == 'critical' %}#incidents{% else %}#monitoring{% endif %}" + "channel": "{% if event.payload.severity == 'critical' %}#incidents{% else %}#monitoring{% endif %}" } } ``` ### 2. Advanced Filters -- Mathematical operations: `{{ trigger.payload.value | multiply: 100 }}` -- String manipulation: `{{ trigger.payload.text | replace: 'old', 'new' }}` -- Array operations: `{{ trigger.payload.items | join: ', ' }}` +- Mathematical operations: `{{ event.payload.value | multiply: 100 }}` +- String manipulation: `{{ event.payload.text | replace: 'old', 'new' }}` +- Array operations: `{{ event.payload.items | join: ', ' }}` ### 3. Custom Functions ```json @@ -695,7 +700,7 @@ Look for the resolved parameters in the execution's `config` field: "action_params": { "timestamp": "{{ now() }}", "uuid": "{{ uuid() }}", - "hash": "{{ hash(trigger.payload.data) }}" + "hash": "{{ hash(event.payload.data) }}" } } ``` @@ -704,7 +709,7 @@ Look for the resolved parameters in the execution's `config` field: ```json { "action_params": { - "user": "{{ trigger.payload.user | merge: pack.config.default_user }}" + "user": "{{ event.payload.user | merge: pack.config.default_user }}" } } ``` @@ -734,7 +739,8 @@ Rule parameter mapping provides a powerful way to: **Key Concepts:** - Static values for constants -- `{{ trigger.payload.* }}` for event data +- `{{ event.payload.* }}` for event payload data +- `{{ event.id }}`, `{{ event.trigger }}`, `{{ event.created }}` for event metadata - `{{ pack.config.* }}` for pack configuration - `{{ system.* }}` for system-provided values - Filters and defaults for robust templates diff --git a/docs/workflows/rule-trigger-params.md b/docs/workflows/rule-trigger-params.md index 8d310b6..3fe2bb6 100644 --- a/docs/workflows/rule-trigger-params.md +++ b/docs/workflows/rule-trigger-params.md @@ -181,12 +181,12 @@ Both `trigger_params` and `conditions` can filter events, but they serve differe }, "conditions": { "and": [ - {"var": "trigger.payload.status_code", ">=": 500}, - {"var": "trigger.payload.retry_count", ">": 3}, + {"var": "event.payload.status_code", ">=": 500}, + {"var": "event.payload.retry_count", ">": 3}, { "or": [ - {"var": "trigger.payload.endpoint", "in": ["/auth", "/payment"]}, - {"var": "trigger.payload.customer_impact", "==": true} + {"var": "event.payload.endpoint", "in": ["/auth", "/payment"]}, + {"var": "event.payload.customer_impact", "==": true} ] } ] @@ -448,7 +448,7 @@ This improves performance by filtering earlier in the evaluation pipeline. "action_ref": "slack.post_message", "action_params": { "channel": "#pull-requests", - "message": "New PR: {{ trigger.payload.title }} by {{ trigger.payload.user }}" + "message": "New PR: {{ event.payload.title }} by {{ event.payload.user }}" } } ``` diff --git a/scripts/setup-test-rules.sh b/scripts/setup-test-rules.sh index 9b2c32c..d4a70f2 100755 --- a/scripts/setup-test-rules.sh +++ b/scripts/setup-test-rules.sh @@ -60,7 +60,7 @@ RULE1=$(curl -s -X POST "$API_URL/api/v1/rules" \ "interval": 1 }, "action_params": { - "message": "Hello from 1-second timer! Time: {{trigger.payload.executed_at}}" + "message": "Hello from 1-second timer! Time: {{event.payload.executed_at}}" } }') @@ -126,7 +126,7 @@ RULE3=$(curl -s -X POST "$API_URL/api/v1/rules" \ "action_params": { "url": "https://httpbin.org/post", "method": "POST", - "body": "{\"message\": \"Test from Attune\", \"timestamp\": \"{{trigger.payload.executed_at}}\", \"rule\": \"test.httpbin_post\"}", + "body": "{\"message\": \"Test from Attune\", \"timestamp\": \"{{event.payload.executed_at}}\", \"rule\": \"test.httpbin_post\"}", "headers": { "Content-Type": "application/json", "User-Agent": "Attune-Test/1.0" diff --git a/tests/e2e/tier3/test_t3_05_rule_criteria.py b/tests/e2e/tier3/test_t3_05_rule_criteria.py index e5e1991..4c95d40 100644 --- a/tests/e2e/tier3/test_t3_05_rule_criteria.py +++ b/tests/e2e/tier3/test_t3_05_rule_criteria.py @@ -80,7 +80,7 @@ def test_rule_criteria_basic_filtering(client: AttuneClient, test_pack): "trigger": trigger_ref, "action": action_info, "enabled": True, - "criteria": "{{ trigger.payload.level == 'info' }}", + "criteria": "{{ event.payload.level == 'info' }}", } rule_info_response = client.create_rule(rule_info_data) @@ -95,7 +95,7 @@ def test_rule_criteria_basic_filtering(client: AttuneClient, test_pack): "trigger": trigger_ref, "action": action_error, "enabled": True, - "criteria": "{{ trigger.payload.level == 'error' }}", + "criteria": "{{ event.payload.level == 'error' }}", } rule_error_response = client.create_rule(rule_error_data) @@ -262,7 +262,7 @@ def test_rule_criteria_numeric_comparison(client: AttuneClient, test_pack): "trigger": trigger_ref, "action": action_low, "enabled": True, - "criteria": "{{ trigger.payload.priority <= 3 }}", + "criteria": "{{ event.payload.priority <= 3 }}", } rule_low = client.create_rule(rule_low_data) print(f"✓ Low priority rule created (priority <= 3)") @@ -273,7 +273,7 @@ def test_rule_criteria_numeric_comparison(client: AttuneClient, test_pack): "trigger": trigger_ref, "action": action_high, "enabled": True, - "criteria": "{{ trigger.payload.priority >= 7 }}", + "criteria": "{{ event.payload.priority >= 7 }}", } rule_high = client.create_rule(rule_high_data) print(f"✓ High priority rule created (priority >= 7)") @@ -356,8 +356,8 @@ def test_rule_criteria_complex_expressions(client: AttuneClient, test_pack): # Criteria: (level == 'error' AND priority > 5) OR environment == 'production' complex_criteria = ( - "{{ (trigger.payload.level == 'error' and trigger.payload.priority > 5) " - "or trigger.payload.environment == 'production' }}" + "{{ (event.payload.level == 'error' and event.payload.priority > 5) " + "or event.payload.environment == 'production' }}" ) rule_data = { @@ -450,7 +450,7 @@ def test_rule_criteria_list_membership(client: AttuneClient, test_pack): print("\n[STEP 2] Creating rule with list membership criteria...") # Criteria: status in ['critical', 'urgent', 'high'] - list_criteria = "{{ trigger.payload.status in ['critical', 'urgent', 'high'] }}" + list_criteria = "{{ event.payload.status in ['critical', 'urgent', 'high'] }}" rule_data = { "name": f"List Membership Rule {unique_ref()}", diff --git a/tests/e2e/tier3/test_t3_07_complex_workflows.py b/tests/e2e/tier3/test_t3_07_complex_workflows.py index 59eebd1..934f6e6 100644 --- a/tests/e2e/tier3/test_t3_07_complex_workflows.py +++ b/tests/e2e/tier3/test_t3_07_complex_workflows.py @@ -282,7 +282,7 @@ def test_conditional_workflow_branching(client: AttuneClient, test_pack): "action": workflow["ref"], "enabled": True, "parameters": { - "condition": "{{ trigger.payload.condition }}", + "condition": "{{ event.payload.condition }}", }, } rule_response = client.post("/rules", json=rule_payload) @@ -665,7 +665,7 @@ print(json.dumps({'result': result, 'step': 2})) "action": workflow["ref"], "enabled": True, "parameters": { - "input_text": "{{ trigger.payload.text }}", + "input_text": "{{ event.payload.text }}", }, } rule_response = client.post("/rules", json=rule_payload) diff --git a/tests/e2e/tier3/test_t3_08_chained_webhooks.py b/tests/e2e/tier3/test_t3_08_chained_webhooks.py index d34f138..96dfcdb 100644 --- a/tests/e2e/tier3/test_t3_08_chained_webhooks.py +++ b/tests/e2e/tier3/test_t3_08_chained_webhooks.py @@ -170,7 +170,7 @@ def test_webhook_triggers_workflow_triggers_webhook(client: AttuneClient, test_p "action": final_action["ref"], "enabled": True, "parameters": { - "message": "{{ trigger.payload.message }}", + "message": "{{ event.payload.message }}", }, } rule_b_response = client.post("/rules", json=rule_b_payload) @@ -522,7 +522,7 @@ print(json.dumps({'transformed_value': transformed, 'original': value})) "action": transform_action["ref"], "enabled": True, "parameters": { - "value": "{{ trigger.payload.input_value }}", + "value": "{{ event.payload.input_value }}", }, } rule_a_response = client.post("/rules", json=rule_a_payload) @@ -539,7 +539,7 @@ print(json.dumps({'transformed_value': transformed, 'original': value})) "action": final_action["ref"], "enabled": True, "parameters": { - "message": "Received: {{ trigger.payload.transformed_value }}", + "message": "Received: {{ event.payload.transformed_value }}", }, } rule_b_response = client.post("/rules", json=rule_b_payload) diff --git a/tests/e2e/tier3/test_t3_16_rule_notifications.py b/tests/e2e/tier3/test_t3_16_rule_notifications.py index b92329d..0a4653f 100644 --- a/tests/e2e/tier3/test_t3_16_rule_notifications.py +++ b/tests/e2e/tier3/test_t3_16_rule_notifications.py @@ -394,7 +394,7 @@ def test_rule_criteria_evaluation_notification(client: AttuneClient, test_pack): "trigger": trigger["ref"], "action": action["ref"], "enabled": True, - "criteria": "{{ trigger.payload.environment == 'production' }}", + "criteria": "{{ event.payload.environment == 'production' }}", "parameters": { "message": "Production deployment approved", }, diff --git a/tests/e2e/tier3/test_t3_17_container_runner.py b/tests/e2e/tier3/test_t3_17_container_runner.py index 3cd0244..60490eb 100644 --- a/tests/e2e/tier3/test_t3_17_container_runner.py +++ b/tests/e2e/tier3/test_t3_17_container_runner.py @@ -212,8 +212,8 @@ print(json.dumps(result)) "action": action["ref"], "enabled": True, "parameters": { - "name": "{{ trigger.payload.name }}", - "count": "{{ trigger.payload.count }}", + "name": "{{ event.payload.name }}", + "count": "{{ event.payload.count }}", }, } rule_response = client.post("/rules", json=rule_payload) diff --git a/tests/test_e2e_basic.py b/tests/test_e2e_basic.py index 0e7d32b..a0d26c9 100644 --- a/tests/test_e2e_basic.py +++ b/tests/test_e2e_basic.py @@ -442,9 +442,9 @@ class TestBasicAutomation: "action_ref": action_ref, "trigger_ref": trigger_ref, "conditions": { - "and": [{"var": "trigger.payload.body.message", "!=": None}] + "and": [{"var": "event.payload.body.message", "!=": None}] }, - "action_params": {"message": "{{ trigger.payload.body.message }}"}, + "action_params": {"message": "{{ event.payload.body.message }}"}, "enabled": True, } diff --git a/work-summary/2026-02-05-template-resolver-refactor.md b/work-summary/2026-02-05-template-resolver-refactor.md new file mode 100644 index 0000000..f8acbd8 --- /dev/null +++ b/work-summary/2026-02-05-template-resolver-refactor.md @@ -0,0 +1,100 @@ +# Work Summary: Template Resolver Refactor + +**Date:** 2026-02-05 + +## Overview + +Moved the template resolver from the sensor crate to the common crate, renamed the template namespace from `trigger.payload` to `event.payload`, added event metadata fields, and wired the resolver into the executor's event processor for real template resolution during enforcement creation. + +## Motivation + +1. **Wrong location:** The template resolver lived in `crates/sensor/` but template resolution happens in the executor when rules create enforcements. The sensor crate was the wrong home. +2. **Misleading naming:** Templates used `trigger.payload.*` to reference data that actually comes from the *event* payload. The trigger defines the schema, but the runtime data is on the event. +3. **Stub in executor:** The executor's `event_processor.rs` had a `resolve_action_params()` stub that just passed action_params through unresolved. Template resolution was never actually happening. + +## Changes + +### Template Resolver Moved to Common Crate + +- **New file:** `crates/common/src/template_resolver.rs` +- **Registered in:** `crates/common/src/lib.rs` with re-exports of `TemplateContext` and `resolve_templates` +- **Sensor crate:** `crates/sensor/src/lib.rs` now re-exports from common (`pub use attune_common::template_resolver::*`) for backward compatibility +- **Deleted:** `crates/sensor/src/template_resolver.rs` (old location) + +### Renamed `trigger.payload` → `event.payload` + +The template namespace was changed from `trigger` to `event` across the entire codebase: + +- **Template syntax:** `{{ trigger.payload.field }}` → `{{ event.payload.field }}` +- **Struct field:** `trigger_payload: JsonValue` → `event: JsonValue` (restructured as a JSON object with `payload`, `id`, `trigger`, `created` sub-keys) +- **Context routing:** `get_value()` now routes `event.*` paths with a skip count of 1 (instead of `trigger` with skip 2) + +### New Event Metadata in Templates + +The `event.*` namespace now provides access to event metadata alongside the payload: + +| Template | Description | +|----------|-------------| +| `{{ event.payload.* }}` | Event payload fields (same data as before, just renamed) | +| `{{ event.id }}` | Event database ID (i64) | +| `{{ event.trigger }}` | Trigger ref that generated the event | +| `{{ event.created }}` | Event creation timestamp (RFC 3339) | + +Builder methods on `TemplateContext`: +- `.with_event_id(id: i64)` +- `.with_event_trigger(trigger_ref: &str)` +- `.with_event_created(created: &str)` + +### Executor Integration + +`crates/executor/src/event_processor.rs` — `resolve_action_params()` was rewritten from a pass-through stub to a real implementation: + +- Builds a `TemplateContext` from the `Event` and `Rule` models +- Populates `event.id`, `event.trigger`, `event.created`, and `event.payload` from the Event +- Populates `system.timestamp`, `system.rule.id`, `system.rule.ref` from the Rule and current time +- Pack config is currently passed as `{}` (TODO: load from database) +- Calls `resolve_templates()` on the rule's `action_params` + +### Documentation Updates + +All documentation files updated from `trigger.payload` to `event.payload`: + +- `docs/api/api-rules.md` +- `docs/cli/cli.md` +- `docs/examples/rule-parameter-examples.md` +- `docs/guides/quickstart-example.md` +- `docs/workflows/dynamic-parameter-forms.md` +- `docs/workflows/parameter-mapping-status.md` (also overhauled to reflect completed implementation) +- `docs/workflows/rule-parameter-mapping.md` (section headers and prose updated too) +- `docs/workflows/rule-trigger-params.md` +- `crates/cli/README.md` + +### Test and Script Updates + +- `scripts/setup-test-rules.sh` — updated template references +- `tests/e2e/tier3/test_t3_05_rule_criteria.py` +- `tests/e2e/tier3/test_t3_07_complex_workflows.py` +- `tests/e2e/tier3/test_t3_08_chained_webhooks.py` +- `tests/e2e/tier3/test_t3_16_rule_notifications.py` +- `tests/e2e/tier3/test_t3_17_container_runner.py` +- `tests/test_e2e_basic.py` + +### Docker Fix (Separate Issue) + +Fixed `ARG NODE_VERSION` scoping in Docker multi-stage builds: +- `docker/Dockerfile.sensor.optimized` — added `ARG NODE_VERSION=20` inside `sensor-full` stage +- `docker/Dockerfile.worker.optimized` — added `ARG NODE_VERSION=20` inside `worker-node` and `worker-full` stages + +Global ARGs are only available in `FROM` instructions; they must be re-declared inside stages to use in `RUN` commands. + +## Test Results + +- **20 unit tests** in `attune_common::template_resolver` — all pass +- **78 executor lib tests** — all pass +- **Sensor tests** — all pass (re-export works correctly) +- **Zero compiler warnings** across workspace + +## Remaining Work + +- **Pack config loading:** The executor currently passes `{}` for pack config context. `{{ pack.config.* }}` templates will resolve to null until pack config is loaded from the database. +- **Work summaries left as-is:** Historical work summaries in `work-summary/` still reference the old `trigger.payload` syntax. These are historical records and were intentionally not updated. \ No newline at end of file