this is all of the changes now

This commit is contained in:
2026-02-18 18:43:42 -06:00
parent 77cf18c02f
commit a1b9b8d2b1
22 changed files with 619 additions and 535 deletions

View File

@@ -35,7 +35,7 @@ attune/
├── config.{development,test}.yaml # Environment configs ├── config.{development,test}.yaml # Environment configs
├── Makefile # Common dev tasks ├── Makefile # Common dev tasks
├── crates/ # Rust services ├── 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) │ ├── api/ # REST API service (8080)
│ ├── executor/ # Execution orchestration service │ ├── executor/ # Execution orchestration service
│ ├── worker/ # Action execution service (multi-runtime) │ ├── 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 - **Available at**: `http://localhost:8080` (dev), `/api-spec/openapi.json` for spec
### Common Library (`crates/common`) ### 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` - **Exports**: Commonly used types re-exported from `lib.rs`
- **Repository Layer**: All DB access goes through repositories in `repositories/` - **Repository Layer**: All DB access goes through repositories in `repositories/`
- **Message Queue**: Abstractions in `mq/` for RabbitMQ communication - **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/`) ### Web UI (`web/`)
- **Generated Client**: OpenAPI client auto-generated from API spec - **Generated Client**: OpenAPI client auto-generated from API spec

View File

@@ -234,7 +234,7 @@ attune rule create \
--pack core \ --pack core \
--trigger core.webhook \ --trigger core.webhook \
--action core.notify \ --action core.notify \
--criteria '{"trigger.payload.severity": "critical"}' --criteria '{"event.payload.severity": "critical"}'
``` ```
### Delete Rule ### Delete Rule

View File

@@ -17,12 +17,14 @@ pub mod pack_registry;
pub mod repositories; pub mod repositories;
pub mod runtime_detection; pub mod runtime_detection;
pub mod schema; pub mod schema;
pub mod template_resolver;
pub mod test_executor; pub mod test_executor;
pub mod utils; pub mod utils;
pub mod workflow; pub mod workflow;
// Re-export commonly used types // Re-export commonly used types
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use template_resolver::{resolve_templates, TemplateContext};
/// Library version /// Library version
pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View File

@@ -1,27 +1,44 @@
//! Template Resolver //! Template Resolver
//! //!
//! Resolves template variables in rule action parameters using context from //! 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 }}` //! 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 //! ```rust
//! use serde_json::json; //! 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!({ //! 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(&params, &context).unwrap(); //! let resolved = resolve_templates(&params, &context).unwrap();
//! assert_eq!(resolved["message"], "Error in api-gateway"); //! assert_eq!(resolved["message"], "Error in api-gateway");
//! assert_eq!(resolved["trigger"], "core.webhook");
//! assert_eq!(resolved["event_id"], 42);
//! ``` //! ```
use anyhow::Result; use anyhow::Result;
@@ -30,33 +47,71 @@ use serde_json::Value as JsonValue;
use std::sync::LazyLock; use std::sync::LazyLock;
use tracing::{debug, warn}; 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)] #[derive(Debug, Clone)]
pub struct TemplateContext { pub struct TemplateContext {
/// Event/trigger payload data /// Event data (payload, id, trigger, created) — accessed as `event.*`
pub trigger_payload: JsonValue, pub event: JsonValue,
/// Pack configuration /// Pack configuration — accessed as `pack.config.*`
pub pack_config: JsonValue, pub pack_config: JsonValue,
/// System-provided variables /// System-provided variables — accessed as `system.*`
pub system_vars: JsonValue, pub system_vars: JsonValue,
} }
impl TemplateContext { impl TemplateContext {
/// Create a new template context /// Create a new template context with an event payload.
pub fn new(trigger_payload: JsonValue, pack_config: JsonValue, system_vars: JsonValue) -> Self { ///
/// 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 { Self {
trigger_payload, event,
pack_config, pack_config,
system_vars, 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: /// Supports paths like:
/// - `trigger.payload.field` /// - `event.payload.field` — event payload data
/// - `pack.config.setting` /// - `event.id` — event ID
/// - `system.timestamp` /// - `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<JsonValue> { pub fn get_value(&self, path: &str) -> Option<JsonValue> {
let parts: Vec<&str> = path.split('.').collect(); let parts: Vec<&str> = path.split('.').collect();
@@ -64,18 +119,12 @@ impl TemplateContext {
return None; return None;
} }
// Determine the root source // Determine the root source and how many path segments to skip
let root = match parts[0] { let (root, skip_count) = match parts[0] {
"trigger" => { "event" => {
// trigger.payload.* paths // event.* paths navigate directly into the event JSON object
if parts.len() < 2 || parts[1] != "payload" { // e.g. event.id, event.trigger, event.created, event.payload.field
warn!( (&self.event, 1)
"Invalid trigger path: {}, expected 'trigger.payload.*'",
path
);
return None;
}
&self.trigger_payload
} }
"pack" => { "pack" => {
// pack.config.* paths // pack.config.* paths
@@ -83,22 +132,15 @@ impl TemplateContext {
warn!("Invalid pack path: {}, expected 'pack.config.*'", path); warn!("Invalid pack path: {}, expected 'pack.config.*'", path);
return None; return None;
} }
&self.pack_config (&self.pack_config, 2)
} }
"system" => &self.system_vars, "system" => (&self.system_vars, 1),
_ => { _ => {
warn!("Unknown template source: {}", parts[0]); warn!("Unknown template source: {}", parts[0]);
return None; 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..]) extract_nested_value(root, &parts[skip_count..])
} }
} }
@@ -108,7 +150,7 @@ static TEMPLATE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\{\{\s*([^}]+?)\s*\}\}").expect("Failed to compile template regex") 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 /// Recursively processes objects and arrays, replacing template strings
/// with values from the context. /// 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, /// If the string contains a single template that matches the entire string,
/// returns the value with its original type (preserving numbers, booleans, etc). /// returns the value with its original type (preserving numbers, booleans, etc).
@@ -192,7 +234,7 @@ fn resolve_string_template(s: &str, context: &TemplateContext) -> Result<JsonVal
Ok(JsonValue::String(result)) Ok(JsonValue::String(result))
} }
/// Extract a nested value from JSON using a path /// Extract a nested value from JSON using a path.
fn extract_nested_value(root: &JsonValue, path: &[&str]) -> Option<JsonValue> { fn extract_nested_value(root: &JsonValue, path: &[&str]) -> Option<JsonValue> {
if path.is_empty() { if path.is_empty() {
return Some(root.clone()); return Some(root.clone());
@@ -220,7 +262,7 @@ fn extract_nested_value(root: &JsonValue, path: &[&str]) -> Option<JsonValue> {
Some(current.clone()) 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 { fn value_to_string(value: &JsonValue) -> String {
match value { match value {
JsonValue::String(s) => s.clone(), JsonValue::String(s) => s.clone(),
@@ -229,7 +271,7 @@ fn value_to_string(value: &JsonValue) -> String {
JsonValue::Null => String::new(), JsonValue::Null => String::new(),
JsonValue::Array(_) | JsonValue::Object(_) => { JsonValue::Array(_) | JsonValue::Object(_) => {
// For complex types, serialize as JSON // 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; use serde_json::json;
fn create_test_context() -> TemplateContext { fn create_test_context() -> TemplateContext {
TemplateContext { TemplateContext::new(
trigger_payload: json!({ json!({
"service": "api-gateway", "service": "api-gateway",
"message": "Connection timeout", "message": "Connection timeout",
"severity": "critical", "severity": "critical",
@@ -253,29 +295,29 @@ mod tests {
}, },
"tags": ["production", "backend"] "tags": ["production", "backend"]
}), }),
pack_config: json!({ json!({
"api_token": "secret123", "api_token": "secret123",
"alert_channel": "#incidents", "alert_channel": "#incidents",
"timeout": 30 "timeout": 30
}), }),
system_vars: json!({ json!({
"timestamp": "2026-01-17T15:30:00Z", "timestamp": "2026-01-17T15:30:00Z",
"rule": { "rule": {
"id": 42, "id": 42,
"ref": "test.rule" "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] #[test]
fn test_simple_string_substitution() { fn test_simple_string_substitution() {
let context = create_test_context(); let context = create_test_context();
let template = json!({ let template = json!({
"message": "Hello {{ trigger.payload.service }}" "message": "Hello {{ event.payload.service }}"
}); });
let result = resolve_templates(&template, &context).unwrap(); let result = resolve_templates(&template, &context).unwrap();
@@ -287,12 +329,12 @@ mod tests {
let context = create_test_context(); let context = create_test_context();
// Number // Number
let template = json!({"count": "{{ trigger.payload.count }}"}); let template = json!({"count": "{{ event.payload.count }}"});
let result = resolve_templates(&template, &context).unwrap(); let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["count"], 42); assert_eq!(result["count"], 42);
// Boolean // Boolean
let template = json!({"enabled": "{{ trigger.payload.enabled }}"}); let template = json!({"enabled": "{{ event.payload.enabled }}"});
let result = resolve_templates(&template, &context).unwrap(); let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["enabled"], true); assert_eq!(result["enabled"], true);
} }
@@ -301,8 +343,8 @@ mod tests {
fn test_nested_object_access() { fn test_nested_object_access() {
let context = create_test_context(); let context = create_test_context();
let template = json!({ let template = json!({
"host": "{{ trigger.payload.metadata.host }}", "host": "{{ event.payload.metadata.host }}",
"port": "{{ trigger.payload.metadata.port }}" "port": "{{ event.payload.metadata.port }}"
}); });
let result = resolve_templates(&template, &context).unwrap(); let result = resolve_templates(&template, &context).unwrap();
@@ -314,8 +356,8 @@ mod tests {
fn test_array_access() { fn test_array_access() {
let context = create_test_context(); let context = create_test_context();
let template = json!({ let template = json!({
"first_tag": "{{ trigger.payload.tags.0 }}", "first_tag": "{{ event.payload.tags.0 }}",
"second_tag": "{{ trigger.payload.tags.1 }}" "second_tag": "{{ event.payload.tags.1 }}"
}); });
let result = resolve_templates(&template, &context).unwrap(); let result = resolve_templates(&template, &context).unwrap();
@@ -342,20 +384,65 @@ mod tests {
let template = json!({ let template = json!({
"timestamp": "{{ system.timestamp }}", "timestamp": "{{ system.timestamp }}",
"rule_id": "{{ system.rule.id }}", "rule_id": "{{ system.rule.id }}",
"event_id": "{{ system.event.id }}"
}); });
let result = resolve_templates(&template, &context).unwrap(); let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["timestamp"], "2026-01-17T15:30:00Z"); assert_eq!(result["timestamp"], "2026-01-17T15:30:00Z");
assert_eq!(result["rule_id"], 42); 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); 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] #[test]
fn test_missing_value_returns_null() { fn test_missing_value_returns_null() {
let context = create_test_context(); let context = create_test_context();
let template = json!({ let template = json!({
"missing": "{{ trigger.payload.nonexistent }}" "missing": "{{ event.payload.nonexistent }}"
}); });
let result = resolve_templates(&template, &context).unwrap(); let result = resolve_templates(&template, &context).unwrap();
@@ -366,7 +453,7 @@ mod tests {
fn test_multiple_templates_in_string() { fn test_multiple_templates_in_string() {
let context = create_test_context(); let context = create_test_context();
let template = json!({ 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(); let result = resolve_templates(&template, &context).unwrap();
@@ -396,11 +483,11 @@ mod tests {
let context = create_test_context(); let context = create_test_context();
let template = json!({ let template = json!({
"nested": { "nested": {
"field1": "{{ trigger.payload.service }}", "field1": "{{ event.payload.service }}",
"field2": "{{ pack.config.timeout }}" "field2": "{{ pack.config.timeout }}"
}, },
"array": [ "array": [
"{{ trigger.payload.severity }}", "{{ event.payload.severity }}",
"static value" "static value"
] ]
}); });
@@ -414,14 +501,10 @@ mod tests {
#[test] #[test]
fn test_empty_template_context() { fn test_empty_template_context() {
let context = TemplateContext { let context = TemplateContext::new(json!({}), json!({}), json!({}));
trigger_payload: json!({}),
pack_config: json!({}),
system_vars: json!({}),
};
let template = json!({ let template = json!({
"message": "{{ trigger.payload.missing }}" "message": "{{ event.payload.missing }}"
}); });
let result = resolve_templates(&template, &context).unwrap(); let result = resolve_templates(&template, &context).unwrap();
@@ -432,7 +515,7 @@ mod tests {
fn test_whitespace_in_templates() { fn test_whitespace_in_templates() {
let context = create_test_context(); let context = create_test_context();
let template = json!({ let template = json!({
"message": "{{ trigger.payload.service }}" "message": "{{ event.payload.service }}"
}); });
let result = resolve_templates(&template, &context).unwrap(); let result = resolve_templates(&template, &context).unwrap();
@@ -444,12 +527,14 @@ mod tests {
let context = create_test_context(); let context = create_test_context();
let template = json!({ let template = json!({
"channel": "{{ pack.config.alert_channel }}", "channel": "{{ pack.config.alert_channel }}",
"message": "🚨 Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}", "message": "🚨 Error in {{ event.payload.service }}: {{ event.payload.message }}",
"severity": "{{ trigger.payload.severity }}", "severity": "{{ event.payload.severity }}",
"details": { "details": {
"host": "{{ trigger.payload.metadata.host }}", "host": "{{ event.payload.metadata.host }}",
"count": "{{ trigger.payload.count }}", "count": "{{ event.payload.count }}",
"tags": "{{ trigger.payload.tags }}" "tags": "{{ event.payload.tags }}",
"event_id": "{{ event.id }}",
"trigger": "{{ event.trigger }}"
}, },
"timestamp": "{{ system.timestamp }}" "timestamp": "{{ system.timestamp }}"
}); });
@@ -463,6 +548,52 @@ mod tests {
assert_eq!(result["severity"], "critical"); assert_eq!(result["severity"], "critical");
assert_eq!(result["details"]["host"], "web-01"); assert_eq!(result["details"]["host"], "web-01");
assert_eq!(result["details"]["count"], 42); 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"); 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());
}
} }

View File

@@ -20,6 +20,7 @@ use attune_common::{
rule::RuleRepository, rule::RuleRepository,
Create, FindById, List, Create, FindById, List,
}, },
template_resolver::{resolve_templates, TemplateContext},
}; };
/// Event processor that handles event-to-rule matching /// Event processor that handles event-to-rule matching
@@ -189,8 +190,8 @@ impl EventProcessor {
.cloned() .cloned()
.unwrap_or_else(|| serde_json::Map::new()); .unwrap_or_else(|| serde_json::Map::new());
// Resolve action parameters (simplified - full template resolution would go here) // Resolve action parameters using the template resolver
let resolved_params = Self::resolve_action_params(&rule.action_params, &payload)?; let resolved_params = Self::resolve_action_params(rule, event, &payload)?;
let create_input = CreateEnforcementInput { let create_input = CreateEnforcementInput {
rule: Some(rule.id), rule: Some(rule.id),
@@ -240,10 +241,7 @@ impl EventProcessor {
let payload = match &event.payload { let payload = match &event.payload {
Some(p) => p, Some(p) => p,
None => { None => {
debug!( debug!("Event {} has no payload, matching by default", event.id);
"Event {} has no payload, matching by default",
event.id
);
return Ok(true); return Ok(true);
} }
}; };
@@ -351,14 +349,43 @@ impl EventProcessor {
Ok(current) 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( fn resolve_action_params(
action_params: &serde_json::Value, rule: &Rule,
_payload: &serde_json::Value, event: &Event,
event_payload: &serde_json::Value,
) -> Result<serde_json::Map<String, serde_json::Value>> { ) -> Result<serde_json::Map<String, serde_json::Value>> {
// For now, just convert to map if it's an object let action_params = &rule.action_params;
// Full implementation would do template resolution
if let Some(obj) = action_params.as_object() { // 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()) Ok(obj.clone())
} else { } else {
Ok(serde_json::Map::new()) Ok(serde_json::Map::new())

View File

@@ -8,7 +8,11 @@ pub mod rule_lifecycle_listener;
pub mod sensor_manager; pub mod sensor_manager;
pub mod sensor_worker_registration; pub mod sensor_worker_registration;
pub mod service; 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 // Re-export commonly used types
pub use rule_lifecycle_listener::RuleLifecycleListener; pub use rule_lifecycle_listener::RuleLifecycleListener;

View File

@@ -32,8 +32,8 @@ Rules are the core automation logic in Attune that connect triggers to actions.
}, },
"action_params": { "action_params": {
"channel": "#alerts", "channel": "#alerts",
"message": "Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}", "message": "Error in {{ event.payload.service }}: {{ event.payload.message }}",
"severity": "{{ trigger.payload.severity }}" "severity": "{{ event.payload.severity }}"
}, },
"enabled": true, "enabled": true,
"created": "2024-01-13T10:00:00Z", "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: The `action_params` field supports both static values and dynamic templates:
- **Static values**: `"channel": "#alerts"` - **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 }}"` - **Dynamic from pack config**: `"token": "{{ pack.config.api_token }}"`
- **System variables**: `"timestamp": "{{ system.timestamp }}"` - **System variables**: `"timestamp": "{{ system.timestamp }}"`
@@ -295,8 +295,8 @@ Create a new rule in the system.
}, },
"action_params": { "action_params": {
"channel": "#alerts", "channel": "#alerts",
"message": "Error detected: {{ trigger.payload.message }}", "message": "Error detected: {{ event.payload.message }}",
"severity": "{{ trigger.payload.severity }}" "severity": "{{ event.payload.severity }}"
}, },
"enabled": true "enabled": true
} }
@@ -314,7 +314,7 @@ Create a new rule in the system.
- `conditions`: JSON Logic conditions for rule evaluation (default: `{}`) - `conditions`: JSON Logic conditions for rule evaluation (default: `{}`)
- `action_params`: Parameters to pass to the action (default: `{}`) - `action_params`: Parameters to pass to the action (default: `{}`)
- Supports static values: `"channel": "#alerts"` - 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 }}"` - Supports pack config: `"token": "{{ pack.config.api_token }}"`
- `enabled`: Whether the rule is active (default: `true`) - `enabled`: Whether the rule is active (default: `true`)
@@ -341,8 +341,8 @@ Create a new rule in the system.
}, },
"action_params": { "action_params": {
"channel": "#alerts", "channel": "#alerts",
"message": "Error detected: {{ trigger.payload.message }}", "message": "Error detected: {{ event.payload.message }}",
"severity": "{{ trigger.payload.severity }}" "severity": "{{ event.payload.severity }}"
}, },
"enabled": true, "enabled": true,
"created": "2024-01-13T10:00:00Z", "created": "2024-01-13T10:00:00Z",
@@ -384,7 +384,7 @@ All fields are optional. Only provided fields will be updated.
}, },
"action_params": { "action_params": {
"channel": "#critical-alerts", "channel": "#critical-alerts",
"message": "CRITICAL: {{ trigger.payload.service }} - {{ trigger.payload.message }}", "message": "CRITICAL: {{ event.payload.service }} - {{ event.payload.message }}",
"priority": "high" "priority": "high"
}, },
"enabled": false "enabled": false
@@ -416,7 +416,7 @@ All fields are optional. Only provided fields will be updated.
}, },
"action_params": { "action_params": {
"channel": "#critical-alerts", "channel": "#critical-alerts",
"message": "CRITICAL: {{ trigger.payload.service }} - {{ trigger.payload.message }}", "message": "CRITICAL: {{ event.payload.service }} - {{ event.payload.message }}",
"priority": "high" "priority": "high"
}, },
"enabled": false, "enabled": false,

View File

@@ -177,7 +177,7 @@ attune rule create \
--pack core \ --pack core \
--trigger core.webhook \ --trigger core.webhook \
--action core.notify \ --action core.notify \
--criteria '{"trigger.payload.severity": "critical"}' --criteria '{"event.payload.severity": "critical"}'
``` ```
#### Delete Rule #### Delete Rule

View File

@@ -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 **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_ref": "slack.post_message",
"action_params": { "action_params": {
"channel": "#incidents", "channel": "#incidents",
"message": "🚨 Error in {{ trigger.payload.service }}: {{ trigger.payload.error }}", "message": "🚨 Error in {{ event.payload.service }}: {{ event.payload.error }}",
"severity": "{{ trigger.payload.severity }}", "severity": "{{ event.payload.severity }}",
"timestamp": "{{ trigger.payload.timestamp }}" "timestamp": "{{ event.payload.timestamp }}"
}, },
"enabled": true "enabled": true
} }
@@ -163,8 +163,8 @@ This document provides practical, copy-paste ready examples of rule parameter ma
"action_params": { "action_params": {
"token": "{{ pack.config.token }}", "token": "{{ pack.config.token }}",
"repo": "{{ pack.config.repo_owner }}/{{ pack.config.repo_name }}", "repo": "{{ pack.config.repo_owner }}/{{ pack.config.repo_name }}",
"title": "[{{ trigger.payload.severity }}] {{ trigger.payload.service }}: {{ trigger.payload.error_message }}", "title": "[{{ event.payload.severity }}] {{ event.payload.service }}: {{ event.payload.error_message }}",
"body": "Error Details:\n\nService: {{ trigger.payload.service }}\nSeverity: {{ trigger.payload.severity }}\n\nStack Trace:\n{{ trigger.payload.stack_trace }}", "body": "Error Details:\n\nService: {{ event.payload.service }}\nSeverity: {{ event.payload.severity }}\n\nStack Trace:\n{{ event.payload.stack_trace }}",
"labels": ["bug", "automated"], "labels": ["bug", "automated"],
"assignees": ["oncall"] "assignees": ["oncall"]
}, },
@@ -219,12 +219,12 @@ This document provides practical, copy-paste ready examples of rule parameter ma
"trigger_ref": "core.user_event", "trigger_ref": "core.user_event",
"action_ref": "audit.log", "action_ref": "audit.log",
"action_params": { "action_params": {
"user_id": "{{ trigger.payload.user.id }}", "user_id": "{{ event.payload.user.id }}",
"user_name": "{{ trigger.payload.user.profile.name }}", "user_name": "{{ event.payload.user.profile.name }}",
"user_email": "{{ trigger.payload.user.profile.email }}", "user_email": "{{ event.payload.user.profile.email }}",
"department": "{{ trigger.payload.user.profile.department }}", "department": "{{ event.payload.user.profile.department }}",
"action": "{{ trigger.payload.action }}", "action": "{{ event.payload.action }}",
"ip_address": "{{ trigger.payload.metadata.ip }}" "ip_address": "{{ event.payload.metadata.ip }}"
}, },
"enabled": true "enabled": true
} }
@@ -271,10 +271,10 @@ This document provides practical, copy-paste ready examples of rule parameter ma
"action_ref": "slack.post_message", "action_ref": "slack.post_message",
"action_params": { "action_params": {
"channel": "#alerts", "channel": "#alerts",
"message": "Primary error: {{ trigger.payload.errors.0 }}", "message": "Primary error: {{ event.payload.errors.0 }}",
"secondary_error": "{{ trigger.payload.errors.1 }}", "secondary_error": "{{ event.payload.errors.1 }}",
"environment": "{{ trigger.payload.tags.0 }}", "environment": "{{ event.payload.tags.0 }}",
"severity": "{{ trigger.payload.tags.1 }}" "severity": "{{ event.payload.tags.1 }}"
}, },
"enabled": true "enabled": true
} }
@@ -377,17 +377,17 @@ This document provides practical, copy-paste ready examples of rule parameter ma
"routing_key": "{{ pack.config.routing_key }}", "routing_key": "{{ pack.config.routing_key }}",
"event_action": "trigger", "event_action": "trigger",
"payload": { "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", "severity": "critical",
"source": "{{ trigger.payload.host }}", "source": "{{ event.payload.host }}",
"custom_details": { "custom_details": {
"metric": "{{ trigger.payload.metric_name }}", "metric": "{{ event.payload.metric_name }}",
"current_value": "{{ trigger.payload.current_value }}", "current_value": "{{ event.payload.current_value }}",
"threshold": "{{ trigger.payload.threshold }}", "threshold": "{{ event.payload.threshold }}",
"duration": "{{ trigger.payload.duration_seconds }}s" "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 "enabled": true
} }
@@ -463,27 +463,27 @@ This document provides practical, copy-paste ready examples of rule parameter ma
"fields": [ "fields": [
{ {
"title": "Service", "title": "Service",
"value": "{{ trigger.payload.service }}", "value": "{{ event.payload.service }}",
"short": true "short": true
}, },
{ {
"title": "Version", "title": "Version",
"value": "{{ trigger.payload.version }}", "value": "{{ event.payload.version }}",
"short": true "short": true
}, },
{ {
"title": "Environment", "title": "Environment",
"value": "{{ trigger.payload.environment }}", "value": "{{ event.payload.environment }}",
"short": true "short": true
}, },
{ {
"title": "Deployed By", "title": "Deployed By",
"value": "{{ trigger.payload.deployed_by }}", "value": "{{ event.payload.deployed_by }}",
"short": true "short": true
} }
], ],
"footer": "Attune Automation", "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", "trigger_ref": "core.alert_event",
"action_ref": "slack.post_message", "action_ref": "slack.post_message",
"action_params": { "action_params": {
"channel": "{{ trigger.payload.severity | default: 'info' | map: {'critical': '#incidents', 'high': '#alerts', 'medium': '#monitoring', 'low': '#logs'} }}", "channel": "{{ event.payload.severity | default: 'info' | map: {'critical': '#incidents', 'high': '#alerts', 'medium': '#monitoring', 'low': '#logs'} }}",
"message": "{{ trigger.payload.message }}", "message": "{{ event.payload.message }}",
"color": "{{ trigger.payload.severity | map: {'critical': 'danger', 'high': 'warning', 'medium': 'good', 'low': '#cccccc'} }}" "color": "{{ event.payload.severity | map: {'critical': 'danger', 'high': 'warning', 'medium': 'good', 'low': '#cccccc'} }}"
}, },
"enabled": true "enabled": true
} }
@@ -584,12 +584,12 @@ The `config` field should contain the same resolved parameters.
```json ```json
{ {
"action_params": { "action_params": {
"summary": "Error: {{ trigger.payload.message }}", "summary": "Error: {{ event.payload.message }}",
"details": { "details": {
"service": "{{ trigger.payload.service }}", "service": "{{ event.payload.service }}",
"host": "{{ trigger.payload.host }}", "host": "{{ event.payload.host }}",
"timestamp": "{{ trigger.payload.timestamp }}", "timestamp": "{{ event.payload.timestamp }}",
"stack_trace": "{{ trigger.payload.stack_trace }}" "stack_trace": "{{ event.payload.stack_trace }}"
} }
} }
} }
@@ -600,11 +600,11 @@ The `config` field should contain the same resolved parameters.
{ {
"action_params": { "action_params": {
"user": { "user": {
"id": "{{ trigger.payload.user.id }}", "id": "{{ event.payload.user.id }}",
"name": "{{ trigger.payload.user.name }}", "name": "{{ event.payload.user.name }}",
"email": "{{ trigger.payload.user.email }}" "email": "{{ event.payload.user.email }}"
}, },
"action": "{{ trigger.payload.action_type }}" "action": "{{ event.payload.action_type }}"
} }
} }
``` ```

View File

@@ -226,7 +226,7 @@ Update the rule to use event data:
```sql ```sql
UPDATE attune.rule 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'; WHERE ref = 'core.rule.timer_10s_echo';
``` ```

View File

@@ -220,7 +220,7 @@ Potential improvements for the parameter form system:
1. **Advanced validation**: Support for min/max, pattern matching, custom validators 1. **Advanced validation**: Support for min/max, pattern matching, custom validators
2. **Conditional fields**: Show/hide fields based on other field values 2. **Conditional fields**: Show/hide fields based on other field values
3. **Field hints**: Helper text, examples, tooltips 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 5. **Schema versioning**: Handle schema changes across pack versions
6. **Array item editing**: Better UX for editing array items individually 6. **Array item editing**: Better UX for editing array items individually
7. **Nested objects**: Support for deeply nested object schemas 7. **Nested objects**: Support for deeply nested object schemas

View File

@@ -2,22 +2,20 @@
## Quick Reference ## 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 ### Database Schema
- **Migration:** `migrations/20240103000003_add_rule_action_params.sql` - **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) - **Index:** `idx_rule_action_params_gin` (GIN index for efficient querying)
- **Status:** ✅ Complete
### Data Models ### Data Models
- **File:** `crates/common/src/models.rs` - **File:** `crates/common/src/models.rs`
- **Struct:** `rule::Rule` has `pub action_params: JsonValue` field - **Struct:** `rule::Rule` has `pub action_params: JsonValue` field
- **Status:** ✅ Complete
### API Layer ### API Layer
- **File:** `crates/api/src/dto/rule.rs` - **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) - `UpdateRuleRequest.action_params` (optional)
- **Response DTOs:** - **Response DTOs:**
- `RuleResponse.action_params` - `RuleResponse.action_params`
- **Status:** ✅ Complete - `RuleSummary.action_params`
### Repository Layer ### Repository Layer
- **File:** `crates/common/src/repositories/rule.rs` - **File:** `crates/common/src/repositories/rule.rs`
- **Operations:** - **Operations:** CREATE, UPDATE, and SELECT all handle `action_params`
- `CreateRuleInput.action_params` included in INSERT
- `UpdateRuleInput.action_params` handled in UPDATE
- All SELECT queries include `action_params` column
- **Status:** ✅ Complete
### API Routes ### Template Resolver Module
- **File:** `crates/api/src/routes/rules.rs` - **File:** `crates/common/src/template_resolver.rs`
- **Handlers:** - **Struct:** `TemplateContext` with `event`, `pack_config`, and `system_vars` fields
- `create_rule()` accepts `action_params` from request - **Function:** `resolve_templates()` — recursively resolves `{{ }}` templates in JSON values
- `update_rule()` updates `action_params` if provided - **Re-exported** from `attune_common::template_resolver` and `attune_common::{TemplateContext, resolve_templates}`
- **Status:** ✅ Complete - **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) Rule.action_params (templates)
resolve_templates() in EventProcessor
Enforcement.config (copied verbatim) Enforcement.config (resolved values)
Execution.config (passed through) Execution.config (passed through)
Worker (receives as action parameters) 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 ## 🔄 Partially Implemented
### 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<JsonValue> {
// Implementation needed
}
```
- **Status:** ❌ Does not exist
### Pack Config Loading ### Pack Config Loading
- **Needed:** Load pack configuration from database - **Current:** Executor passes empty `{}` for `pack.config` context
- **Current:** Rule matcher doesn't load pack config - **Needed:** Load pack configuration from database before template resolution
- **Required for:** `{{ pack.config.* }}` templates - **Impact:** `{{ pack.config.* }}` templates resolve to `null` until implemented
- **Status:** ❌ Not implemented - **TODO comment** in `event_processor.rs` marks the location
### 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
--- ---
## 📋 Implementation Checklist ## 📋 Remaining Work
### Phase 1: MVP (2-3 days) ### Phase 1: Complete Core (Short-term)
- [ ] **Create template resolver module** - [ ] **Pack config loading** — Load pack config from database for `{{ pack.config.* }}` resolution
- [ ] Define `TemplateContext` struct - [ ] **Integration tests** — End-to-end test: create rule with templates → fire event → verify enforcement has resolved params
- [ ] 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)
- [ ] **Add pack config loading** ### Phase 2: Advanced Features (Future)
- [ ] Add method to load pack config from database
- [ ] Implement in-memory cache with TTL
- [ ] Handle missing pack config gracefully
- [ ] **Integrate with rule matcher** - [ ] **Default values** — Parse `| default: 'value'` syntax for fallback values
- [ ] Update `create_enforcement()` method - [ ] **Filters**`upper`, `lower`, `trim`, `date`, `truncate`, `json`
- [ ] Load pack config before resolution - [ ] **Conditional templates**`{% if event.payload.severity == 'critical' %}...{% endif %}`
- [ ] Build template context - [ ] **Performance** — Skip resolution early if no `{{ }}` patterns detected in action_params
- [ ] 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>` - Format timestamp
- [ ] `truncate: <length>` - 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
--- ---
## 🔍 Key Implementation Details ## 🔍 Template Example
### Current Enforcement Creation (line 306-348) **Input (Rule `action_params`):**
```rust
async fn create_enforcement(&self, rule: &Rule, event: &Event) -> Result<Id> {
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
}
```
### Template Examples
**Input (Rule):**
```json ```json
{ {
"action_params": { "message": "Error in {{ event.payload.service }}: {{ event.payload.message }}",
"message": "Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}",
"channel": "{{ pack.config.alert_channel }}", "channel": "{{ pack.config.alert_channel }}",
"severity": "{{ trigger.payload.severity }}" "severity": "{{ event.payload.severity }}",
} "event_id": "{{ event.id }}",
"trigger": "{{ event.trigger }}"
} }
``` ```
**Context:** **Context (built from Event + Rule):**
```json ```json
{ {
"trigger": { "event": {
"id": 456,
"trigger": "core.error_event",
"created": "2026-02-05T10:00:00Z",
"payload": { "payload": {
"service": "api-gateway", "service": "api-gateway",
"message": "Connection timeout", "message": "Connection timeout",
@@ -262,114 +143,30 @@ async fn create_enforcement(&self, rule: &Rule, event: &Event) -> Result<Id> {
"config": { "config": {
"alert_channel": "#incidents" "alert_channel": "#incidents"
} }
},
"system": {
"timestamp": "2026-02-05T10:00:01Z",
"rule": { "id": 42, "ref": "alerts.error_notification" }
} }
} }
``` ```
**Output (Enforcement):** **Output (Enforcement `config`):**
```json ```json
{ {
"config": {
"message": "Error in api-gateway: Connection timeout", "message": "Error in api-gateway: Connection timeout",
"channel": "#incidents", "channel": "#incidents",
"severity": "critical" "severity": "critical",
} "event_id": 456,
"trigger": "core.error_event"
} }
``` ```
--- ---
## 📊 Dependencies ## Related Documentation
### Existing (Already in Cargo.toml) - [Rule Parameter Mapping Guide](./rule-parameter-mapping.md)
- `serde_json` - JSON manipulation ✅ - [Rule Parameter Examples](../examples/rule-parameter-examples.md)
- `regex` - Pattern matching ✅ - [Rule Management API](../api/api-rules.md)
- `anyhow` - Error handling ✅ - [Executor Service Architecture](../architecture/executor-service.md)
- `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)

View File

@@ -5,7 +5,7 @@
Rules in Attune can specify parameters to pass to actions when triggered. These parameters can be: 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 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 3. **Dynamic from pack config** - Values from the pack's configuration
This enables flexible parameter passing without hardcoding values or requiring custom code. 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:** **Available Sources:**
- `trigger.payload.*` - Data from the event payload - `event.payload.*` - Data from the event payload
- `pack.config.*` - Configuration values from the pack - `pack.config.*` - Configuration values from the pack
- `system.*` - System-provided values (timestamp, execution context) - `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. Extract values from the event that triggered the rule.
### Example: Alert with Event Data ### Example: Alert with Event Data
**Trigger Payload:** **Event Payload:**
```json ```json
{ {
"severity": "error", "severity": "error",
@@ -84,10 +84,10 @@ Extract values from the event that triggered the rule.
"action_ref": "slack.post_message", "action_ref": "slack.post_message",
"action_params": { "action_params": {
"channel": "#incidents", "channel": "#incidents",
"message": "Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}", "message": "Error in {{ event.payload.service }}: {{ event.payload.message }}",
"severity": "{{ trigger.payload.severity }}", "severity": "{{ event.payload.severity }}",
"host": "{{ trigger.payload.metadata.host }}", "host": "{{ event.payload.metadata.host }}",
"timestamp": "{{ trigger.payload.timestamp }}" "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 }}", "token": "{{ pack.config.api_token }}",
"channel": "{{ pack.config.default_channel }}", "channel": "{{ pack.config.default_channel }}",
"username": "{{ pack.config.bot_name }}", "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": { "action_params": {
"repo": "myorg/myrepo", "repo": "myorg/myrepo",
"token": "{{ pack.config.github_token }}", "token": "{{ pack.config.github_token }}",
"title": "Error: {{ trigger.payload.message }}", "title": "Error: {{ event.payload.message }}",
"body": "Service {{ trigger.payload.service }} reported an error at {{ trigger.payload.timestamp }}", "body": "Service {{ event.payload.service }} reported an error at {{ event.payload.timestamp }}",
"labels": ["bug", "automated"], "labels": ["bug", "automated"],
"assignees": ["oncall"] "assignees": ["oncall"]
} }
@@ -177,11 +177,11 @@ Access nested properties using dot notation:
```json ```json
{ {
"action_params": { "action_params": {
"user_id": "{{ trigger.payload.user.id }}", "user_id": "{{ event.payload.user.id }}",
"user_name": "{{ trigger.payload.user.profile.name }}", "user_name": "{{ event.payload.user.profile.name }}",
"metadata": { "metadata": {
"ip_address": "{{ trigger.payload.request.client_ip }}", "ip_address": "{{ event.payload.request.client_ip }}",
"user_agent": "{{ trigger.payload.request.headers.user_agent }}" "user_agent": "{{ event.payload.request.headers.user_agent }}"
} }
} }
} }
@@ -196,8 +196,8 @@ Access array elements by index:
```json ```json
{ {
"action_params": { "action_params": {
"first_error": "{{ trigger.payload.errors.0 }}", "first_error": "{{ event.payload.errors.0 }}",
"primary_tag": "{{ trigger.payload.tags.0 }}" "primary_tag": "{{ event.payload.tags.0 }}"
} }
} }
``` ```
@@ -211,8 +211,8 @@ Provide default values when the referenced field doesn't exist:
```json ```json
{ {
"action_params": { "action_params": {
"priority": "{{ trigger.payload.priority | default: 'medium' }}", "priority": "{{ event.payload.priority | default: 'medium' }}",
"assignee": "{{ trigger.payload.assignee | default: 'unassigned' }}" "assignee": "{{ event.payload.assignee | default: 'unassigned' }}"
} }
} }
``` ```
@@ -226,10 +226,10 @@ Template values preserve their JSON types:
```json ```json
{ {
"action_params": { "action_params": {
"count": "{{ trigger.payload.count }}", // Number: 42 "count": "{{ event.payload.count }}", // Number: 42
"enabled": "{{ trigger.payload.enabled }}", // Boolean: true "enabled": "{{ event.payload.enabled }}", // Boolean: true
"tags": "{{ trigger.payload.tags }}", // Array: ["a", "b"] "tags": "{{ event.payload.tags }}", // Array: ["a", "b"]
"metadata": "{{ trigger.payload.metadata }}" // Object: {"key": "value"} "metadata": "{{ event.payload.metadata }}" // Object: {"key": "value"}
} }
} }
``` ```
@@ -261,8 +261,8 @@ Embed multiple values in a single string:
```json ```json
{ {
"action_params": { "action_params": {
"message": "User {{ trigger.payload.user_id }} performed {{ trigger.payload.action }} at {{ system.timestamp }}", "message": "User {{ event.payload.user_id }} performed {{ event.payload.action }} at {{ system.timestamp }}",
"subject": "[{{ trigger.payload.severity | upper }}] {{ trigger.payload.service }} Alert" "subject": "[{{ event.payload.severity | upper }}] {{ event.payload.service }} Alert"
} }
} }
``` ```
@@ -276,10 +276,10 @@ Apply transformations to values:
```json ```json
{ {
"action_params": { "action_params": {
"uppercase_name": "{{ trigger.payload.name | upper }}", "uppercase_name": "{{ event.payload.name | upper }}",
"lowercase_email": "{{ trigger.payload.email | lower }}", "lowercase_email": "{{ event.payload.email | lower }}",
"formatted_date": "{{ trigger.payload.timestamp | date: '%Y-%m-%d' }}", "formatted_date": "{{ event.payload.timestamp | date: '%Y-%m-%d' }}",
"truncated": "{{ trigger.payload.message | truncate: 100 }}" "truncated": "{{ event.payload.message | truncate: 100 }}"
} }
} }
``` ```
@@ -310,19 +310,19 @@ Apply transformations to values:
"action_params": { "action_params": {
"channel": "{{ pack.config.alert_channel }}", "channel": "{{ pack.config.alert_channel }}",
"token": "{{ pack.config.slack_token }}", "token": "{{ pack.config.slack_token }}",
"message": "⚠️ Alert from {{ trigger.payload.source }}: {{ trigger.payload.message }}", "message": "⚠️ Alert from {{ event.payload.source }}: {{ event.payload.message }}",
"attachments": [ "attachments": [
{ {
"color": "{{ trigger.payload.severity | default: 'warning' }}", "color": "{{ event.payload.severity | default: 'warning' }}",
"fields": [ "fields": [
{ {
"title": "Service", "title": "Service",
"value": "{{ trigger.payload.service }}", "value": "{{ event.payload.service }}",
"short": true "short": true
}, },
{ {
"title": "Environment", "title": "Environment",
"value": "{{ trigger.payload.environment | default: 'production' }}", "value": "{{ event.payload.environment | default: 'production' }}",
"short": true "short": true
} }
], ],
@@ -349,7 +349,7 @@ Apply transformations to values:
"token": "{{ pack.config.jira_token }}" "token": "{{ pack.config.jira_token }}"
}, },
"issuetype": "Bug", "issuetype": "Bug",
"summary": "[{{ trigger.payload.severity }}] {{ trigger.payload.service }}: {{ trigger.payload.message }}", "summary": "[{{ event.payload.severity }}] {{ event.payload.service }}: {{ event.payload.message }}",
"description": { "description": {
"type": "doc", "type": "doc",
"content": [ "content": [
@@ -358,14 +358,14 @@ Apply transformations to values:
"content": [ "content": [
{ {
"type": "text", "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' }}", "priority": "{{ event.payload.priority | default: 'Medium' }}",
"labels": ["automated", "{{ trigger.payload.service }}"] "labels": ["automated", "{{ event.payload.service }}"]
} }
} }
``` ```
@@ -382,17 +382,17 @@ Apply transformations to values:
"routing_key": "{{ pack.config.pagerduty_routing_key }}", "routing_key": "{{ pack.config.pagerduty_routing_key }}",
"event_action": "trigger", "event_action": "trigger",
"payload": { "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", "severity": "critical",
"source": "{{ trigger.payload.host }}", "source": "{{ event.payload.host }}",
"custom_details": { "custom_details": {
"metric": "{{ trigger.payload.metric_name }}", "metric": "{{ event.payload.metric_name }}",
"current_value": "{{ trigger.payload.current_value }}", "current_value": "{{ event.payload.current_value }}",
"threshold": "{{ trigger.payload.threshold }}", "threshold": "{{ event.payload.threshold }}",
"duration": "{{ trigger.payload.duration_seconds }}s" "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 1. **Rule Evaluation** - When an event matches a rule
2. **Template Extraction** - Identify `{{ }}` patterns in `action_params` 2. **Template Extraction** - Identify `{{ }}` patterns in `action_params`
3. **Context Building** - Assemble available data: 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 - `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 4. **Value Resolution** - Extract values from context using dot notation paths
5. **Type Conversion** - Preserve JSON types (string, number, boolean, object, array) 5. **Type Conversion** - Preserve JSON types (string, number, boolean, object, array)
6. **Parameter Assembly** - Build final parameter object 6. **Parameter Assembly** - Build final parameter object
@@ -444,7 +447,7 @@ Apply transformations to values:
**Missing Values:** **Missing Values:**
- If a referenced value doesn't exist and no default is provided, use `null` - 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:** **Invalid Syntax:**
- If template syntax is invalid, log error and use the raw string - 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 ```json
{ {
"action_params": { "action_params": {
"priority": "{{ trigger.payload.priority | default: 'medium' }}", "priority": "{{ event.payload.priority | default: 'medium' }}",
"assignee": "{{ trigger.payload.assignee | default: 'unassigned' }}" "assignee": "{{ event.payload.assignee | default: 'unassigned' }}"
} }
} }
``` ```
@@ -516,8 +519,8 @@ Pack configuration should be stored securely and can include:
```json ```json
{ {
"action_params": { "action_params": {
"user_email": "{{ trigger.payload.user.email }}", "user_email": "{{ event.payload.user.email }}",
"user_id": "{{ trigger.payload.user.id }}" "user_id": "{{ event.payload.user.id }}"
} }
} }
``` ```
@@ -528,7 +531,7 @@ If a value never changes, keep it static:
{ {
"action_params": { "action_params": {
"service_name": "my-service", // Static - never changes "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, "id": 1,
"config": { "config": {
"message": "Test message", // Resolved from trigger.payload.message "message": "Test message", // Resolved from event.payload.message
"severity": "info", // Resolved from trigger.payload.severity "severity": "info", // Resolved from event.payload.severity
"user_id": 123, // Resolved from trigger.payload.user.id "user_id": 123, // Resolved from event.payload.user.id
"user_name": "Alice" // Resolved from trigger.payload.user.name "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 ```json
{ {
"action_params": { "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 ```json
{ {
"action_params": { "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 ### 2. Advanced Filters
- Mathematical operations: `{{ trigger.payload.value | multiply: 100 }}` - Mathematical operations: `{{ event.payload.value | multiply: 100 }}`
- String manipulation: `{{ trigger.payload.text | replace: 'old', 'new' }}` - String manipulation: `{{ event.payload.text | replace: 'old', 'new' }}`
- Array operations: `{{ trigger.payload.items | join: ', ' }}` - Array operations: `{{ event.payload.items | join: ', ' }}`
### 3. Custom Functions ### 3. Custom Functions
```json ```json
@@ -695,7 +700,7 @@ Look for the resolved parameters in the execution's `config` field:
"action_params": { "action_params": {
"timestamp": "{{ now() }}", "timestamp": "{{ now() }}",
"uuid": "{{ uuid() }}", "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 ```json
{ {
"action_params": { "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:** **Key Concepts:**
- Static values for constants - 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 - `{{ pack.config.* }}` for pack configuration
- `{{ system.* }}` for system-provided values - `{{ system.* }}` for system-provided values
- Filters and defaults for robust templates - Filters and defaults for robust templates

View File

@@ -181,12 +181,12 @@ Both `trigger_params` and `conditions` can filter events, but they serve differe
}, },
"conditions": { "conditions": {
"and": [ "and": [
{"var": "trigger.payload.status_code", ">=": 500}, {"var": "event.payload.status_code", ">=": 500},
{"var": "trigger.payload.retry_count", ">": 3}, {"var": "event.payload.retry_count", ">": 3},
{ {
"or": [ "or": [
{"var": "trigger.payload.endpoint", "in": ["/auth", "/payment"]}, {"var": "event.payload.endpoint", "in": ["/auth", "/payment"]},
{"var": "trigger.payload.customer_impact", "==": true} {"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_ref": "slack.post_message",
"action_params": { "action_params": {
"channel": "#pull-requests", "channel": "#pull-requests",
"message": "New PR: {{ trigger.payload.title }} by {{ trigger.payload.user }}" "message": "New PR: {{ event.payload.title }} by {{ event.payload.user }}"
} }
} }
``` ```

View File

@@ -60,7 +60,7 @@ RULE1=$(curl -s -X POST "$API_URL/api/v1/rules" \
"interval": 1 "interval": 1
}, },
"action_params": { "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": { "action_params": {
"url": "https://httpbin.org/post", "url": "https://httpbin.org/post",
"method": "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": { "headers": {
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "Attune-Test/1.0" "User-Agent": "Attune-Test/1.0"

View File

@@ -80,7 +80,7 @@ def test_rule_criteria_basic_filtering(client: AttuneClient, test_pack):
"trigger": trigger_ref, "trigger": trigger_ref,
"action": action_info, "action": action_info,
"enabled": True, "enabled": True,
"criteria": "{{ trigger.payload.level == 'info' }}", "criteria": "{{ event.payload.level == 'info' }}",
} }
rule_info_response = client.create_rule(rule_info_data) 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, "trigger": trigger_ref,
"action": action_error, "action": action_error,
"enabled": True, "enabled": True,
"criteria": "{{ trigger.payload.level == 'error' }}", "criteria": "{{ event.payload.level == 'error' }}",
} }
rule_error_response = client.create_rule(rule_error_data) 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, "trigger": trigger_ref,
"action": action_low, "action": action_low,
"enabled": True, "enabled": True,
"criteria": "{{ trigger.payload.priority <= 3 }}", "criteria": "{{ event.payload.priority <= 3 }}",
} }
rule_low = client.create_rule(rule_low_data) rule_low = client.create_rule(rule_low_data)
print(f"✓ Low priority rule created (priority <= 3)") 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, "trigger": trigger_ref,
"action": action_high, "action": action_high,
"enabled": True, "enabled": True,
"criteria": "{{ trigger.payload.priority >= 7 }}", "criteria": "{{ event.payload.priority >= 7 }}",
} }
rule_high = client.create_rule(rule_high_data) rule_high = client.create_rule(rule_high_data)
print(f"✓ High priority rule created (priority >= 7)") 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' # Criteria: (level == 'error' AND priority > 5) OR environment == 'production'
complex_criteria = ( complex_criteria = (
"{{ (trigger.payload.level == 'error' and trigger.payload.priority > 5) " "{{ (event.payload.level == 'error' and event.payload.priority > 5) "
"or trigger.payload.environment == 'production' }}" "or event.payload.environment == 'production' }}"
) )
rule_data = { 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...") print("\n[STEP 2] Creating rule with list membership criteria...")
# Criteria: status in ['critical', 'urgent', 'high'] # 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 = { rule_data = {
"name": f"List Membership Rule {unique_ref()}", "name": f"List Membership Rule {unique_ref()}",

View File

@@ -282,7 +282,7 @@ def test_conditional_workflow_branching(client: AttuneClient, test_pack):
"action": workflow["ref"], "action": workflow["ref"],
"enabled": True, "enabled": True,
"parameters": { "parameters": {
"condition": "{{ trigger.payload.condition }}", "condition": "{{ event.payload.condition }}",
}, },
} }
rule_response = client.post("/rules", json=rule_payload) rule_response = client.post("/rules", json=rule_payload)
@@ -665,7 +665,7 @@ print(json.dumps({'result': result, 'step': 2}))
"action": workflow["ref"], "action": workflow["ref"],
"enabled": True, "enabled": True,
"parameters": { "parameters": {
"input_text": "{{ trigger.payload.text }}", "input_text": "{{ event.payload.text }}",
}, },
} }
rule_response = client.post("/rules", json=rule_payload) rule_response = client.post("/rules", json=rule_payload)

View File

@@ -170,7 +170,7 @@ def test_webhook_triggers_workflow_triggers_webhook(client: AttuneClient, test_p
"action": final_action["ref"], "action": final_action["ref"],
"enabled": True, "enabled": True,
"parameters": { "parameters": {
"message": "{{ trigger.payload.message }}", "message": "{{ event.payload.message }}",
}, },
} }
rule_b_response = client.post("/rules", json=rule_b_payload) 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"], "action": transform_action["ref"],
"enabled": True, "enabled": True,
"parameters": { "parameters": {
"value": "{{ trigger.payload.input_value }}", "value": "{{ event.payload.input_value }}",
}, },
} }
rule_a_response = client.post("/rules", json=rule_a_payload) 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"], "action": final_action["ref"],
"enabled": True, "enabled": True,
"parameters": { "parameters": {
"message": "Received: {{ trigger.payload.transformed_value }}", "message": "Received: {{ event.payload.transformed_value }}",
}, },
} }
rule_b_response = client.post("/rules", json=rule_b_payload) rule_b_response = client.post("/rules", json=rule_b_payload)

View File

@@ -394,7 +394,7 @@ def test_rule_criteria_evaluation_notification(client: AttuneClient, test_pack):
"trigger": trigger["ref"], "trigger": trigger["ref"],
"action": action["ref"], "action": action["ref"],
"enabled": True, "enabled": True,
"criteria": "{{ trigger.payload.environment == 'production' }}", "criteria": "{{ event.payload.environment == 'production' }}",
"parameters": { "parameters": {
"message": "Production deployment approved", "message": "Production deployment approved",
}, },

View File

@@ -212,8 +212,8 @@ print(json.dumps(result))
"action": action["ref"], "action": action["ref"],
"enabled": True, "enabled": True,
"parameters": { "parameters": {
"name": "{{ trigger.payload.name }}", "name": "{{ event.payload.name }}",
"count": "{{ trigger.payload.count }}", "count": "{{ event.payload.count }}",
}, },
} }
rule_response = client.post("/rules", json=rule_payload) rule_response = client.post("/rules", json=rule_payload)

View File

@@ -442,9 +442,9 @@ class TestBasicAutomation:
"action_ref": action_ref, "action_ref": action_ref,
"trigger_ref": trigger_ref, "trigger_ref": trigger_ref,
"conditions": { "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, "enabled": True,
} }

View File

@@ -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.