From 999b43193bc10b8f63de3ba779b010d839219aa0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 2 Oct 2026 18:32:26 +0000 Subject: [PATCH] feat(dc_rulekit): v0.2 industry-standard rule documents Align rule JSON with json-rules-engine conventions: nested conditions (all/any/not), events with type+params, schema_version 2. Add v0.1 ingest compat, optional plugin params JSON Schema at propose time, ADR 0002, schema/fixtures, and mirrored Rust/Dart updates. Co-authored-by: Uday Chauhan --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 26 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 89 +++++++--- crates/dc_rulekit/Cargo.toml | 2 +- crates/dc_rulekit/src/conditions.rs | 122 +++++++++++++ crates/dc_rulekit/src/engine.rs | 104 +++++++---- crates/dc_rulekit/src/error.rs | 6 + crates/dc_rulekit/src/lib.rs | 7 +- crates/dc_rulekit/src/model.rs | 141 ++++++++++++--- crates/dc_rulekit/src/plugin.rs | 32 +++- crates/dc_rulekit/src/proposal.rs | 5 +- crates/dc_rulekit/src/schema_validate.rs | 71 ++++++++ crates/dc_rulekit/tests/engine_tests.rs | 158 +++++++++++++---- crates/dc_rulekit/tests/schema_golden.rs | 38 ++++ crates/dc_rulekit_demo_plugins/src/lib.rs | 36 +++- .../ADR/0001-agnostic-core-vs-host-plugins.md | 2 +- .../0002-industry-standard-rule-documents.md | 50 ++++++ packages/dc_rulekit/CHANGELOG.md | 14 +- packages/dc_rulekit/lib/dc_rulekit.dart | 1 + packages/dc_rulekit/lib/src/conditions.dart | 163 ++++++++++++++++++ packages/dc_rulekit/lib/src/engine.dart | 65 +++++-- packages/dc_rulekit/lib/src/error.dart | 12 ++ packages/dc_rulekit/lib/src/model.dart | 123 +++++++------ packages/dc_rulekit/lib/src/plugin.dart | 34 +++- packages/dc_rulekit/lib/src/proposal.dart | 5 +- .../dc_rulekit/lib/src/schema_validate.dart | 51 ++++++ packages/dc_rulekit/pubspec.yaml | 2 +- packages/dc_rulekit/test/engine_test.dart | 141 +++++++++++---- schema/fixtures/v1_legacy_rule.json | 20 +++ schema/fixtures/v2_basic_rule.json | 24 +++ schema/fixtures/v2_nested_conditions.json | 33 ++++ schema/rule.schema.json | 89 ++++++++++ 34 files changed, 1405 insertions(+), 269 deletions(-) create mode 100644 crates/dc_rulekit/src/conditions.rs create mode 100644 crates/dc_rulekit/src/schema_validate.rs create mode 100644 crates/dc_rulekit/tests/schema_golden.rs create mode 100644 docs/ADR/0002-industry-standard-rule-documents.md create mode 100644 packages/dc_rulekit/lib/src/conditions.dart create mode 100644 packages/dc_rulekit/lib/src/schema_validate.dart create mode 100644 schema/fixtures/v1_legacy_rule.json create mode 100644 schema/fixtures/v2_basic_rule.json create mode 100644 schema/fixtures/v2_nested_conditions.json create mode 100644 schema/rule.schema.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c340777..f9086fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.85 - run: cargo test --all + - name: Golden rule fixtures + run: cargo test -p dc_rulekit --test schema_golden dart: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc199e..a8a0310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to this project will be documented in this file. +## [0.2.0] - 2026-10-02 + +### Changed (breaking) + +- Rule document **v2** (`schema_version: 2`): nested `conditions` (`all` / `any` / `not`) instead of flat `when[]`; `events[]` with `{ id, type, params }` instead of `then[]` with `plugin`. +- Rust/Dart APIs: `Rule.conditions`, `Rule.events`, `RuleEvent`, `ConditionNode`; `ProposalStore::propose(rule, registry)` validates plugin params against optional JSON Schema. +- Audit `action_outcomes` use `type` (event type) instead of `plugin`. + +### Added + +- ADR 0002 — alignment with json-rules-engine / JSON Logic conventions. +- `schema/rule.schema.json` and golden fixtures; Rust CI tests validate fixtures against schema. +- Optional `params_schema()` on condition/action plugins; fail-closed validation at propose time (Rust + Dart: draft-07 subset validator). +- v0.1 JSON ingest compat: `when` / `then` / `plugin` normalize to v2 on read. + +### Migration + +| v0.1 | v0.2 | +|------|------| +| `"schema_version": 1` | `"schema_version": 2` | +| `"when": [ { "id", "plugin", "params" } ]` | `"conditions": { "all": [ ... ] }` | +| `"then": [ { "id", "plugin", "params" } ]` | `"events": [ { "id", "type", "params" } ]` | +| `proposals.propose(rule)` | `proposals.propose(rule, ®istry)` | + +Cruftkit and other hosts pinned to `^0.1.0` keep working on crates.io/pub.dev **0.1.x** until they opt into `0.2`. + ## [0.1.0] - 2026-10-02 ### Added diff --git a/Cargo.lock b/Cargo.lock index 8bbd3fe..0c2b919 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,7 +67,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "dc_rulekit" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3a63027..a999ba6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,4 +16,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" uuid = { version = "=1.11.0", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde"] } +chrono = { version = "0.4", features = ["serde"] } \ No newline at end of file diff --git a/README.md b/README.md index cc1ebc3..7263e6c 100644 --- a/README.md +++ b/README.md @@ -9,24 +9,70 @@ Host applications register **condition** and **action** plugins; the core valida ## Concepts -- **Rule**: JSON document with `when` (conditions) and `then` (actions), optional `trigger`, `source` (`static` | `llm`). -- **Plugins**: String ids → host `ConditionEvaluator` / `ActionHandler`. -- **Lifecycle**: `propose` → `approve` → active rule in store. -- **Evaluate**: Ordered conditions (all must pass), ordered actions, optional **dry-run** and **audit receipt** hook. +- **Rule (v2)**: JSON with nested `conditions` (`all` / `any` / `not`, json-rules-engine style) and `events` (`{ id, type, params }`). +- **Facts**: Plain JSON on [`EvalContext`](crates/dc_rulekit/src/context.rs) — no proprietary context shape. +- **Plugins**: String ids → host `ConditionEvaluator` / `ActionHandler`; optional JSON Schema on params (validated at **propose**). +- **Lifecycle**: `propose` → `approve` → active rule in store (unchanged from v0.1). +- **Evaluate**: Tree evaluation, ordered events, optional **dry-run** and **audit receipt** hook. + +See [docs/ADR/0001-agnostic-core-vs-host-plugins.md](docs/ADR/0001-agnostic-core-vs-host-plugins.md) and [docs/ADR/0002-industry-standard-rule-documents.md](docs/ADR/0002-industry-standard-rule-documents.md). + +## Rule JSON an LLM can emit (v0.2) + +```json +{ + "schema_version": 2, + "id": "my.app/high-value-alert", + "title": "Alert when value crosses threshold", + "source": "llm", + "enabled": true, + "trigger": { "type": "event", "topic": "facts.updated" }, + "conditions": { + "all": [ + { + "id": "check-value", + "plugin": "my.when.threshold", + "params": { "key": "order_total", "min": 100 } + } + ] + }, + "events": [ + { + "id": "notify", + "type": "my.then.notify", + "params": { "channel": "ops", "message": "High value order" } + } + ] +} +``` + +Nested logic (familiar from json-rules-engine): + +```json +"conditions": { + "any": [ + { "all": [ + { "id": "a", "plugin": "my.when.always", "params": {} }, + { "not": { "id": "b", "plugin": "my.when.maintenance", "params": {} } } + ] + } + ] +} +``` -See [docs/ADR/0001-agnostic-core-vs-host-plugins.md](docs/ADR/0001-agnostic-core-vs-host-plugins.md). +**Migration from v0.1:** flat `when` / `then` with `plugin` still **parse** (upgraded to v2 in memory). New documents should use `schema_version: 2`, `conditions`, and `events[].type`. Cruftkit on `dc_rulekit ^0.1.0` is unaffected until it upgrades to `0.2`. ## Rust quickstart ```toml [dependencies] -dc_rulekit = "0.1" +dc_rulekit = "0.2" ``` ```rust use dc_rulekit::{ - Action, Condition, Engine, EvalContext, EvaluateOptions, PluginRegistry, - ProposalStore, Rule, RuleSource, RuleStore, + Condition, ConditionNode, Engine, EvalContext, EvaluateOptions, PluginRegistry, + ProposalStore, Rule, RuleEvent, RuleSource, RuleStore, }; use dc_rulekit_demo_plugins::{register_all, PLUGIN_ALWAYS, PLUGIN_LOG}; use serde_json::json; @@ -36,25 +82,27 @@ register_all(&mut registry); let engine = Engine::new(®istry); let mut rule = Rule::new("demo.app/hello", "Hello", RuleSource::Static); -rule.when.push(Condition { +rule.conditions = ConditionNode::all(vec![ConditionNode::leaf(Condition { id: "always".into(), plugin: PLUGIN_ALWAYS.into(), params: json!({}), -}); -rule.then.push(Action { +})]); +rule.events.push(RuleEvent { id: "log".into(), - plugin: PLUGIN_LOG.into(), + event_type: PLUGIN_LOG.into(), params: json!({ "message": "hello from dc_rulekit" }), }); let mut proposals = ProposalStore::in_memory(); let mut active = RuleStore::in_memory(); -let proposal = proposals.propose(rule).unwrap(); -let active_rule = proposals.approve(&proposal.proposal_id, &mut active).unwrap(); - -let receipt = engine - .evaluate(&active_rule, &EvalContext::new("demo.app"), EvaluateOptions::default()) - .unwrap(); +let proposal = proposals.propose(rule, ®istry)?; +let active_rule = proposals.approve(&proposal.proposal_id, &mut active)?; + +let receipt = engine.evaluate( + &active_rule, + &EvalContext::new("demo.app"), + EvaluateOptions::default(), +)?; assert!(receipt.matched); ``` @@ -74,7 +122,7 @@ cargo test -p dc_rulekit_demo_plugins quickstart_runs ```yaml dependencies: - dc_rulekit: ^0.1.0 + dc_rulekit: ^0.2.0 ``` ```dart @@ -85,7 +133,7 @@ final registry = PluginRegistry() ..registerAction(/* host ActionHandler */); final engine = Engine(registry); -// Same propose → approve → evaluate flow as Rust. +// Same propose(rule, registry) → approve → evaluate flow as Rust. ``` ```bash @@ -99,6 +147,7 @@ cd packages/dc_rulekit && dart pub get && dart test | `crates/dc_rulekit` | Rust core (crates.io: `dc_rulekit`) | | `crates/dc_rulekit_demo_plugins` | Toy plugins for examples/tests | | `packages/dc_rulekit` | Dart package (pub.dev: `dc_rulekit`) | +| `schema/` | `rule.schema.json` + golden fixtures (CI) | | `docs/ADR/` | Architecture decisions | ## License diff --git a/crates/dc_rulekit/Cargo.toml b/crates/dc_rulekit/Cargo.toml index ee10f50..4ed2d01 100644 --- a/crates/dc_rulekit/Cargo.toml +++ b/crates/dc_rulekit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dc_rulekit" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" description = "Business-agnostic on-device rules engine with host-supplied plugins" diff --git a/crates/dc_rulekit/src/conditions.rs b/crates/dc_rulekit/src/conditions.rs new file mode 100644 index 0000000..747c7e9 --- /dev/null +++ b/crates/dc_rulekit/src/conditions.rs @@ -0,0 +1,122 @@ +use crate::error::{Result, RulekitError}; +use serde::{Deserialize, Serialize}; + +/// Leaf condition: host plugin invocation (json-rules-engine–style tree leaf). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Condition { + pub id: String, + /// Plugin id (alias `fact` accepted on ingest for LLM-friendly JSON). + #[serde(alias = "fact")] + pub plugin: String, + #[serde(default)] + pub params: serde_json::Value, +} + +/// Nested condition tree (`all` / `any` / `not`), aligned with json-rules-engine conventions. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ConditionNode { + All { + all: Vec, + }, + Any { + any: Vec, + }, + Not { + not: Box, + }, + Leaf(Condition), +} + +impl Default for ConditionNode { + fn default() -> Self { + Self::All { all: Vec::new() } + } +} + +impl ConditionNode { + pub fn all(nodes: Vec) -> Self { + Self::All { all: nodes } + } + + pub fn any(nodes: Vec) -> Self { + Self::Any { any: nodes } + } + + pub fn not(inner: ConditionNode) -> Self { + Self::Not { + not: Box::new(inner), + } + } + + pub fn leaf(condition: Condition) -> Self { + Self::Leaf(condition) + } + + /// Flat list of plugin leaves (pre-order). + pub fn leaves(&self) -> Vec<&Condition> { + let mut out = Vec::new(); + self.collect_leaves(&mut out); + out + } + + fn collect_leaves<'a>(&'a self, out: &mut Vec<&'a Condition>) { + match self { + Self::All { all } => { + for child in all { + child.collect_leaves(out); + } + } + Self::Any { any } => { + for child in any { + child.collect_leaves(out); + } + } + Self::Not { not } => not.collect_leaves(out), + Self::Leaf(c) => out.push(c), + } + } + + pub fn validate_shape(&self) -> Result<()> { + match self { + Self::All { all } | Self::Any { any: all } => { + for child in all { + child.validate_shape()?; + } + Ok(()) + } + Self::Not { not } => not.validate_shape(), + Self::Leaf(c) => { + if c.id.is_empty() || c.plugin.is_empty() { + return Err(RulekitError::EvaluationError { + message: "condition leaf requires non-empty id and plugin".into(), + }); + } + Ok(()) + } + } + } +} + +/// Event-style action (`type` + `params`), familiar from json-rules-engine `event` objects. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RuleEvent { + pub id: String, + #[serde(rename = "type", alias = "plugin")] + pub event_type: String, + #[serde(default)] + pub params: serde_json::Value, +} + +impl RuleEvent { + pub fn plugin_id(&self) -> &str { + &self.event_type + } +} + +/// v0.1 flat `when` array → v0.2 `conditions.all` wrapper. +pub fn conditions_from_v1_when(when: Vec) -> ConditionNode { + ConditionNode::All { + all: when.into_iter().map(ConditionNode::Leaf).collect(), + } +} diff --git a/crates/dc_rulekit/src/engine.rs b/crates/dc_rulekit/src/engine.rs index 80188fd..91e31de 100644 --- a/crates/dc_rulekit/src/engine.rs +++ b/crates/dc_rulekit/src/engine.rs @@ -1,9 +1,8 @@ use crate::audit::AuditHook; +use crate::conditions::ConditionNode; use crate::context::EvalContext; use crate::error::{Result, RulekitError}; -use crate::model::{ - ActionOutcome, AuditReceipt, ConditionOutcome, Rule, -}; +use crate::model::{ActionOutcome, AuditReceipt, ConditionOutcome, Rule}; use crate::plugin::PluginRegistry; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,7 +25,7 @@ impl<'a> Engine<'a> { Self { registry } } - /// Validate plugins and evaluate `when` conditions (all must pass), then run `then` actions. + /// Validate plugins and evaluate `conditions` (json-rules-engine tree), then run `events`. pub fn evaluate( &self, rule: &Rule, @@ -41,47 +40,28 @@ impl<'a> Engine<'a> { } self.registry.validate_rule_plugins(rule)?; - let mut condition_results = Vec::with_capacity(rule.when.len()); - let mut all_passed = true; - - for condition in &rule.when { - let evaluator = self.registry.get_condition(&condition.plugin)?; - let passed = evaluator.evaluate(&condition.params, ctx)?; - if !passed { - all_passed = false; - condition_results.push(ConditionOutcome { - condition_id: condition.id.clone(), - plugin: condition.plugin.clone(), - passed: false, - detail: None, - }); - // Fail-fast on first failing condition (ordered evaluation). - break; - } - condition_results.push(ConditionOutcome { - condition_id: condition.id.clone(), - plugin: condition.plugin.clone(), - passed: true, - detail: None, - }); - } + let mut condition_results = Vec::new(); + let all_passed = self.evaluate_conditions( + &rule.conditions, + ctx, + &mut condition_results, + true, + )?; let mut action_outcomes = Vec::new(); if all_passed { - for action in &rule.then { - let handler = self.registry.get_action(&action.plugin)?; + for action in &rule.events { + let handler = self.registry.get_action(action.plugin_id())?; let (executed, skipped_dry_run, result) = if options.dry_run && !handler.is_pure() { (false, true, None) } else { match handler.execute(&action.params, ctx) { Ok(value) => (true, false, Some(value)), - Err(RulekitError::ActionDenied { - reason, .. - }) => { + Err(RulekitError::ActionDenied { reason, .. }) => { return Err(RulekitError::ActionDenied { rule_id: rule.id.clone(), action_id: action.id.clone(), - plugin_id: action.plugin.clone(), + plugin_id: action.plugin_id().to_string(), reason, }); } @@ -90,7 +70,7 @@ impl<'a> Engine<'a> { }; action_outcomes.push(ActionOutcome { action_id: action.id.clone(), - plugin: action.plugin.clone(), + event_type: action.event_type.clone(), executed, skipped_dry_run, result, @@ -108,6 +88,60 @@ impl<'a> Engine<'a> { }) } + fn evaluate_conditions( + &self, + node: &ConditionNode, + ctx: &EvalContext, + outcomes: &mut Vec, + fail_fast: bool, + ) -> Result { + match node { + ConditionNode::All { all } => { + if all.is_empty() { + return Ok(true); + } + for child in all { + let passed = self.evaluate_conditions(child, ctx, outcomes, fail_fast)?; + if !passed { + return Ok(false); + } + } + Ok(true) + } + ConditionNode::Any { any } => { + if any.is_empty() { + return Ok(false); + } + for child in any { + let mut branch_outcomes = Vec::new(); + let passed = + self.evaluate_conditions(child, ctx, &mut branch_outcomes, false)?; + if passed { + outcomes.extend(branch_outcomes); + return Ok(true); + } + outcomes.extend(branch_outcomes); + } + Ok(false) + } + ConditionNode::Not { not } => { + let inner = self.evaluate_conditions(not, ctx, outcomes, false)?; + Ok(!inner) + } + ConditionNode::Leaf(condition) => { + let evaluator = self.registry.get_condition(&condition.plugin)?; + let passed = evaluator.evaluate(&condition.params, ctx)?; + outcomes.push(ConditionOutcome { + condition_id: condition.id.clone(), + plugin: condition.plugin.clone(), + passed, + detail: None, + }); + Ok(passed) + } + } + } + pub fn evaluate_with_audit( &self, rule: &Rule, diff --git a/crates/dc_rulekit/src/error.rs b/crates/dc_rulekit/src/error.rs index 8bbb4de..09cf784 100644 --- a/crates/dc_rulekit/src/error.rs +++ b/crates/dc_rulekit/src/error.rs @@ -43,6 +43,12 @@ pub enum RulekitError { #[error("evaluation error: {message}")] EvaluationError { message: String }, + + #[error("invalid plugin params: plugin={plugin_id}, {message}")] + InvalidPluginParams { + plugin_id: String, + message: String, + }, } pub type Result = std::result::Result; diff --git a/crates/dc_rulekit/src/lib.rs b/crates/dc_rulekit/src/lib.rs index 43c921e..6a63030 100644 --- a/crates/dc_rulekit/src/lib.rs +++ b/crates/dc_rulekit/src/lib.rs @@ -4,21 +4,24 @@ //! The core validates, stores, and evaluates rules without domain-specific effects. mod audit; +mod conditions; mod context; mod engine; mod error; mod model; mod plugin; mod proposal; +mod schema_validate; mod store; pub use audit::{AuditHook, NoopAuditHook}; pub use context::EvalContext; pub use engine::{Engine, EvaluateOptions}; pub use error::{Result, RulekitError}; +pub use conditions::{Condition, ConditionNode, RuleEvent}; pub use model::{ - Action, ActionOutcome, AuditReceipt, Condition, ConditionOutcome, ProposalStatus, Rule, - RuleProposal, RuleSource, Trigger, CURRENT_SCHEMA_VERSION, + Action, ActionOutcome, AuditReceipt, ConditionOutcome, ProposalStatus, Rule, RuleProposal, + RuleSource, Trigger, CURRENT_SCHEMA_VERSION, READABLE_SCHEMA_VERSIONS, }; pub use plugin::{ActionHandler, ConditionEvaluator, PluginRegistry}; pub use proposal::ProposalStore; diff --git a/crates/dc_rulekit/src/model.rs b/crates/dc_rulekit/src/model.rs index 02d2d3e..6849f83 100644 --- a/crates/dc_rulekit/src/model.rs +++ b/crates/dc_rulekit/src/model.rs @@ -1,9 +1,14 @@ use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use uuid::Uuid; +use crate::conditions::{conditions_from_v1_when, Condition, ConditionNode, RuleEvent}; + /// Current rule document schema version written by this crate. -pub const CURRENT_SCHEMA_VERSION: u32 = 1; +pub const CURRENT_SCHEMA_VERSION: u32 = 2; + +/// Schema versions accepted when reading rule JSON (v1 normalized to v2 in memory). +pub const READABLE_SCHEMA_VERSIONS: &[u32] = &[1, 2]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -21,7 +26,7 @@ pub enum Trigger { topic: String, }, Schedule { - /// Cron-like or ISO8601 duration string — interpreted by the host scheduler (not v0.1 runner). + /// Cron-like or ISO8601 duration string — interpreted by the host scheduler (not core runner). expression: String, }, } @@ -32,23 +37,7 @@ impl Default for Trigger { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Condition { - pub id: String, - pub plugin: String, - #[serde(default)] - pub params: serde_json::Value, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Action { - pub id: String, - pub plugin: String, - #[serde(default)] - pub params: serde_json::Value, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct Rule { pub schema_version: u32, pub id: String, @@ -59,15 +48,18 @@ pub struct Rule { #[serde(default)] pub trigger: Trigger, #[serde(default)] - pub when: Vec, + pub conditions: ConditionNode, #[serde(default)] - pub then: Vec, + pub events: Vec, } fn default_enabled() -> bool { true } +/// Back-compat type alias (v0.1 name). +pub type Action = RuleEvent; + impl Rule { pub fn new(id: impl Into, title: impl Into, source: RuleSource) -> Self { Self { @@ -77,8 +69,8 @@ impl Rule { source, enabled: true, trigger: Trigger::default(), - when: Vec::new(), - then: Vec::new(), + conditions: ConditionNode::default(), + events: Vec::new(), } } @@ -89,10 +81,108 @@ impl Rule { found: self.schema_version, }); } + self.conditions.validate_shape()?; + for ev in &self.events { + if ev.id.is_empty() || ev.event_type.is_empty() { + return Err(crate::error::RulekitError::EvaluationError { + message: "event requires non-empty id and type".into(), + }); + } + } Ok(()) } } +#[derive(Debug, Deserialize)] +struct RuleCompatRaw { + schema_version: Option, + id: String, + title: String, + source: RuleSource, + #[serde(default = "default_enabled")] + enabled: bool, + #[serde(default)] + trigger: Trigger, + #[serde(default)] + conditions: Option, + #[serde(default)] + when: Vec, + #[serde(default)] + events: Vec, + #[serde(default)] + then: Vec, +} + +#[derive(Debug, Deserialize)] +struct RuleEventV1 { + id: String, + #[serde(default)] + plugin: Option, + #[serde(rename = "type", default)] + event_type: Option, + #[serde(default)] + params: serde_json::Value, +} + +impl RuleEventV1 { + fn into_v2(self) -> RuleEvent { + let event_type = self + .event_type + .or(self.plugin) + .unwrap_or_default(); + RuleEvent { + id: self.id, + event_type, + params: self.params, + } + } +} + +fn normalize_rule_raw(raw: RuleCompatRaw) -> crate::error::Result { + let version = raw.schema_version.unwrap_or(CURRENT_SCHEMA_VERSION); + if !READABLE_SCHEMA_VERSIONS.contains(&version) { + return Err(crate::error::RulekitError::SchemaVersionMismatch { + expected: CURRENT_SCHEMA_VERSION, + found: version, + }); + } + + let conditions = if let Some(c) = raw.conditions { + c + } else if !raw.when.is_empty() || version == 1 { + conditions_from_v1_when(raw.when) + } else { + ConditionNode::default() + }; + + let events = if !raw.events.is_empty() { + raw.events + } else { + raw.then.into_iter().map(RuleEventV1::into_v2).collect() + }; + + Ok(Rule { + schema_version: CURRENT_SCHEMA_VERSION, + id: raw.id, + title: raw.title, + source: raw.source, + enabled: raw.enabled, + trigger: raw.trigger, + conditions, + events, + }) +} + +impl<'de> Deserialize<'de> for Rule { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RuleCompatRaw::deserialize(deserializer)?; + normalize_rule_raw(raw).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProposalStatus { @@ -144,7 +234,8 @@ pub struct ConditionOutcome { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ActionOutcome { pub action_id: String, - pub plugin: String, + #[serde(rename = "type")] + pub event_type: String, pub executed: bool, pub skipped_dry_run: bool, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/dc_rulekit/src/plugin.rs b/crates/dc_rulekit/src/plugin.rs index c96fc1d..c8fa9cd 100644 --- a/crates/dc_rulekit/src/plugin.rs +++ b/crates/dc_rulekit/src/plugin.rs @@ -1,11 +1,16 @@ use crate::context::EvalContext; use crate::error::{Result, RulekitError}; +use crate::schema_validate::validate_params_against_schema; use serde_json::Value; /// Evaluates a single condition plugin invocation. pub trait ConditionEvaluator: Send + Sync { fn plugin_id(&self) -> &str; fn evaluate(&self, params: &Value, ctx: &EvalContext) -> Result; + /// Optional JSON Schema (draft-07) for `params`; validated at propose time when set. + fn params_schema(&self) -> Option { + None + } } /// Executes a single action plugin invocation. @@ -14,6 +19,10 @@ pub trait ActionHandler: Send + Sync { /// When `true`, dry-run may invoke the handler (no side effects). When `false`, dry-run skips execution and records the planned action. fn is_pure(&self) -> bool; fn execute(&self, params: &Value, ctx: &EvalContext) -> Result; + /// Optional JSON Schema (draft-07) for `params`; validated at propose time when set. + fn params_schema(&self) -> Option { + None + } } /// Registry of host-supplied condition and action plugins (fail-closed on unknown ids). @@ -57,11 +66,28 @@ impl PluginRegistry { } pub fn validate_rule_plugins(&self, rule: &crate::model::Rule) -> Result<()> { - for c in &rule.when { + for c in rule.conditions.leaves() { self.get_condition(&c.plugin)?; } - for a in &rule.then { - self.get_action(&a.plugin)?; + for a in &rule.events { + self.get_action(a.plugin_id())?; + } + Ok(()) + } + + /// Fail-closed JSON Schema validation for plugin params (propose-time). + pub fn validate_rule_params(&self, rule: &crate::model::Rule) -> Result<()> { + for c in rule.conditions.leaves() { + let evaluator = self.get_condition(&c.plugin)?; + if let Some(schema) = evaluator.params_schema() { + validate_params_against_schema(&c.plugin, &c.params, &schema)?; + } + } + for a in &rule.events { + let handler = self.get_action(a.plugin_id())?; + if let Some(schema) = handler.params_schema() { + validate_params_against_schema(a.plugin_id(), &a.params, &schema)?; + } } Ok(()) } diff --git a/crates/dc_rulekit/src/proposal.rs b/crates/dc_rulekit/src/proposal.rs index 1f42a9c..b0ed457 100644 --- a/crates/dc_rulekit/src/proposal.rs +++ b/crates/dc_rulekit/src/proposal.rs @@ -1,5 +1,6 @@ use crate::error::{Result, RulekitError}; use crate::model::{ProposalStatus, Rule, RuleProposal}; +use crate::plugin::PluginRegistry; use crate::store::RuleStore; use std::collections::HashMap; use std::fs; @@ -67,8 +68,10 @@ impl ProposalStore { Ok(()) } - pub fn propose(&mut self, rule: Rule) -> Result { + pub fn propose(&mut self, rule: Rule, registry: &PluginRegistry) -> Result { rule.validate_schema()?; + registry.validate_rule_plugins(&rule)?; + registry.validate_rule_params(&rule)?; let proposal = RuleProposal::new(rule); self.proposals .insert(proposal.proposal_id.clone(), proposal.clone()); diff --git a/crates/dc_rulekit/src/schema_validate.rs b/crates/dc_rulekit/src/schema_validate.rs new file mode 100644 index 0000000..39de496 --- /dev/null +++ b/crates/dc_rulekit/src/schema_validate.rs @@ -0,0 +1,71 @@ +use crate::error::{Result, RulekitError}; +use serde_json::Value; + +/// Minimal draft-07 subset: object type, required keys, property types, additionalProperties. +pub fn validate_params_against_schema( + plugin_id: &str, + params: &Value, + schema: &Value, +) -> Result<()> { + let Some(obj) = params.as_object() else { + return Err(RulekitError::InvalidPluginParams { + plugin_id: plugin_id.to_string(), + message: "params must be a JSON object".into(), + }); + }; + + if schema.get("type").and_then(|v| v.as_str()) == Some("object") { + if schema.get("additionalProperties") == Some(&Value::Bool(false)) { + let allowed: std::collections::HashSet<_> = schema + .get("properties") + .and_then(|v| v.as_object()) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + for key in obj.keys() { + if !allowed.contains(key) { + return Err(RulekitError::InvalidPluginParams { + plugin_id: plugin_id.to_string(), + message: format!("additional property not allowed: {key}"), + }); + } + } + } + if let Some(required) = schema.get("required").and_then(|v| v.as_array()) { + for req in required { + let Some(key) = req.as_str() else { continue }; + if !obj.contains_key(key) { + return Err(RulekitError::InvalidPluginParams { + plugin_id: plugin_id.to_string(), + message: format!("missing required property: {key}"), + }); + } + } + } + if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { + for (key, value) in obj { + if let Some(prop_schema) = props.get(key) { + validate_value(plugin_id, key, value, prop_schema)?; + } + } + } + } + Ok(()) +} + +fn validate_value(plugin_id: &str, path: &str, value: &Value, schema: &Value) -> Result<()> { + match schema.get("type").and_then(|v| v.as_str()) { + Some("string") if !value.is_string() => Err(RulekitError::InvalidPluginParams { + plugin_id: plugin_id.to_string(), + message: format!("{path} must be string"), + }), + Some("number") if !value.is_number() => Err(RulekitError::InvalidPluginParams { + plugin_id: plugin_id.to_string(), + message: format!("{path} must be number"), + }), + Some("boolean") if value.as_bool().is_none() => Err(RulekitError::InvalidPluginParams { + plugin_id: plugin_id.to_string(), + message: format!("{path} must be boolean"), + }), + _ => Ok(()), + } +} diff --git a/crates/dc_rulekit/tests/engine_tests.rs b/crates/dc_rulekit/tests/engine_tests.rs index 4da5855..463af5c 100644 --- a/crates/dc_rulekit/tests/engine_tests.rs +++ b/crates/dc_rulekit/tests/engine_tests.rs @@ -1,6 +1,6 @@ use dc_rulekit::{ - Action, ActionHandler, AuditHook, Condition, ConditionEvaluator, Engine, EvaluateOptions, - EvalContext, PluginRegistry, ProposalStore, Rule, RuleSource, RuleStore, RulekitError, + AuditHook, Condition, ConditionEvaluator, ConditionNode, Engine, EvaluateOptions, EvalContext, + PluginRegistry, ProposalStore, Rule, RuleEvent, RuleSource, RuleStore, RulekitError, }; use serde_json::{json, Value}; use std::sync::{Arc, Mutex}; @@ -25,10 +25,30 @@ impl ConditionEvaluator for NeverCondition { } } +struct SchemaCondition; +impl ConditionEvaluator for SchemaCondition { + fn plugin_id(&self) -> &str { + "demo.when.schema" + } + fn evaluate(&self, _params: &Value, _ctx: &EvalContext) -> dc_rulekit::Result { + Ok(true) + } + fn params_schema(&self) -> Option { + Some(json!({ + "type": "object", + "required": ["min"], + "properties": { + "min": { "type": "number" } + }, + "additionalProperties": false + })) + } +} + struct LogAction { log: Arc>>, } -impl ActionHandler for LogAction { +impl dc_rulekit::ActionHandler for LogAction { fn plugin_id(&self) -> &str { "demo.then.log" } @@ -46,7 +66,7 @@ impl ActionHandler for LogAction { } struct PureEchoAction; -impl ActionHandler for PureEchoAction { +impl dc_rulekit::ActionHandler for PureEchoAction { fn plugin_id(&self) -> &str { "demo.then.echo" } @@ -72,6 +92,7 @@ fn registry_with_demo() -> (PluginRegistry, Arc>>) { let mut reg = PluginRegistry::new(); reg.register_condition(Box::new(AlwaysCondition)); reg.register_condition(Box::new(NeverCondition)); + reg.register_condition(Box::new(SchemaCondition)); reg.register_action(Box::new(LogAction { log: Arc::clone(&log), })); @@ -79,21 +100,26 @@ fn registry_with_demo() -> (PluginRegistry, Arc>>) { (reg, log) } -#[test] -fn evaluate_runs_actions_when_conditions_pass() { - let (reg, log) = registry_with_demo(); - let engine = Engine::new(®); +fn rule_with_always_log() -> Rule { let mut rule = Rule::new("demo.app/rule-1", "Test", RuleSource::Static); - rule.when.push(Condition { + rule.conditions = ConditionNode::all(vec![ConditionNode::leaf(Condition { id: "c1".into(), plugin: "demo.when.always".into(), params: json!({}), - }); - rule.then.push(Action { + })]); + rule.events.push(RuleEvent { id: "a1".into(), - plugin: "demo.then.log".into(), + event_type: "demo.then.log".into(), params: json!({ "message": "hello" }), }); + rule +} + +#[test] +fn evaluate_runs_actions_when_conditions_pass() { + let (reg, log) = registry_with_demo(); + let engine = Engine::new(®); + let rule = rule_with_always_log(); let receipt = engine .evaluate(&rule, &EvalContext::new("demo.app"), EvaluateOptions::default()) @@ -103,21 +129,41 @@ fn evaluate_runs_actions_when_conditions_pass() { } #[test] -fn dry_run_skips_impure_actions() { +fn nested_any_condition() { let (reg, log) = registry_with_demo(); let engine = Engine::new(®); - let mut rule = Rule::new("demo.app/rule-2", "Dry", RuleSource::Static); - rule.when.push(Condition { - id: "c1".into(), - plugin: "demo.when.always".into(), - params: json!({}), - }); - rule.then.push(Action { - id: "a1".into(), - plugin: "demo.then.log".into(), - params: json!({}), + let mut rule = Rule::new("demo.app/any", "Any", RuleSource::Static); + rule.conditions = ConditionNode::any(vec![ + ConditionNode::leaf(Condition { + id: "n1".into(), + plugin: "demo.when.never".into(), + params: json!({}), + }), + ConditionNode::leaf(Condition { + id: "a1".into(), + plugin: "demo.when.always".into(), + params: json!({}), + }), + ]); + rule.events.push(RuleEvent { + id: "e1".into(), + event_type: "demo.then.log".into(), + params: json!({ "message": "any" }), }); + let receipt = engine + .evaluate(&rule, &EvalContext::new("demo.app"), EvaluateOptions::default()) + .unwrap(); + assert!(receipt.matched); + assert_eq!(log.lock().unwrap().len(), 1); +} + +#[test] +fn dry_run_skips_impure_actions() { + let (reg, log) = registry_with_demo(); + let engine = Engine::new(®); + let rule = rule_with_always_log(); + let receipt = engine .evaluate( &rule, @@ -136,14 +182,14 @@ fn dry_run_runs_pure_actions() { let (reg, _log) = registry_with_demo(); let engine = Engine::new(®); let mut rule = Rule::new("demo.app/rule-3", "Pure dry", RuleSource::Static); - rule.when.push(Condition { + rule.conditions = ConditionNode::all(vec![ConditionNode::leaf(Condition { id: "c1".into(), plugin: "demo.when.always".into(), params: json!({}), - }); - rule.then.push(Action { + })]); + rule.events.push(RuleEvent { id: "a1".into(), - plugin: "demo.then.echo".into(), + event_type: "demo.then.echo".into(), params: json!({ "x": 1 }), }); @@ -162,11 +208,11 @@ fn unknown_plugin_fail_closed() { let (reg, _) = registry_with_demo(); let engine = Engine::new(®); let mut rule = Rule::new("demo.app/rule-4", "Bad", RuleSource::Static); - rule.when.push(Condition { + rule.conditions = ConditionNode::all(vec![ConditionNode::leaf(Condition { id: "c1".into(), plugin: "unknown.plugin".into(), params: json!({}), - }); + })]); let err = engine .evaluate(&rule, &EvalContext::new("demo.app"), EvaluateOptions::default()) @@ -179,16 +225,17 @@ fn unknown_plugin_fail_closed() { #[test] fn proposal_approve_and_store_roundtrip() { + let (reg, _) = registry_with_demo(); let mut proposals = ProposalStore::in_memory(); let mut active = RuleStore::in_memory(); let mut rule = Rule::new("demo.app/rule-prop", "Prop", RuleSource::Static); - rule.when.push(Condition { + rule.conditions = ConditionNode::all(vec![ConditionNode::leaf(Condition { id: "c1".into(), plugin: "demo.when.always".into(), params: json!({}), - }); + })]); - let proposal = proposals.propose(rule).unwrap(); + let proposal = proposals.propose(rule, ®).unwrap(); assert_eq!(proposal.status, dc_rulekit::ProposalStatus::Proposed); let active_rule = proposals.approve(&proposal.proposal_id, &mut active).unwrap(); @@ -196,14 +243,49 @@ fn proposal_approve_and_store_roundtrip() { assert!(active.get("demo.app/rule-prop").is_ok()); } +#[test] +fn propose_rejects_invalid_params_schema() { + let (reg, _) = registry_with_demo(); + let mut proposals = ProposalStore::in_memory(); + let mut rule = Rule::new("demo.app/bad-params", "Bad", RuleSource::Static); + rule.conditions = ConditionNode::all(vec![ConditionNode::leaf(Condition { + id: "c1".into(), + plugin: "demo.when.schema".into(), + params: json!({ "wrong": true }), + })]); + + let err = proposals.propose(rule, ®).unwrap_err(); + assert!(matches!(err, RulekitError::InvalidPluginParams { .. })); +} + +#[test] +fn v1_rule_json_compat_deserializes_and_runs() { + let (reg, log) = registry_with_demo(); + let engine = Engine::new(®); + let v1 = json!({ + "schema_version": 1, + "id": "demo.app/v1", + "title": "Legacy", + "source": "static", + "when": [{ "id": "c1", "plugin": "demo.when.always", "params": {} }], + "then": [{ "id": "a1", "plugin": "demo.then.log", "params": { "message": "legacy" } }] + }); + let rule: Rule = serde_json::from_value(v1).unwrap(); + assert_eq!(rule.schema_version, 2); + engine + .evaluate(&rule, &EvalContext::new("demo.app"), EvaluateOptions::default()) + .unwrap(); + assert_eq!(log.lock().unwrap().len(), 1); +} + #[test] fn store_file_roundtrip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("rules.json"); let mut rule = Rule::new("demo.app/file", "File", RuleSource::Static); - rule.then.push(Action { + rule.events.push(RuleEvent { id: "a1".into(), - plugin: "demo.then.log".into(), + event_type: "demo.then.log".into(), params: json!({}), }); @@ -220,14 +302,14 @@ fn audit_hook_receives_opaque_payload() { let (reg, _) = registry_with_demo(); let engine = Engine::new(®); let mut rule = Rule::new("demo.app/audit", "Audit", RuleSource::Static); - rule.when.push(Condition { + rule.conditions = ConditionNode::all(vec![ConditionNode::leaf(Condition { id: "c1".into(), plugin: "demo.when.always".into(), params: json!({}), - }); - rule.then.push(Action { + })]); + rule.events.push(RuleEvent { id: "a1".into(), - plugin: "demo.then.echo".into(), + event_type: "demo.then.echo".into(), params: json!({}), }); diff --git a/crates/dc_rulekit/tests/schema_golden.rs b/crates/dc_rulekit/tests/schema_golden.rs new file mode 100644 index 0000000..d689924 --- /dev/null +++ b/crates/dc_rulekit/tests/schema_golden.rs @@ -0,0 +1,38 @@ +use dc_rulekit::Rule; +use serde_json::Value; +use std::fs; +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") +} + +fn fixture(name: &str) -> Value { + let path = repo_root().join("schema/fixtures").join(name); + let text = fs::read_to_string(&path).expect("fixture readable"); + serde_json::from_str(&text).expect("valid fixture json") +} + +#[test] +fn golden_v2_fixtures_deserialize_and_validate() { + for name in ["v2_basic_rule.json", "v2_nested_conditions.json"] { + let doc = fixture(name); + assert_eq!( + doc.get("schema_version").and_then(|v| v.as_u64()), + Some(2), + "{name}" + ); + let rule: Rule = serde_json::from_value(doc).expect("fixture deserializes to Rule"); + rule.validate_schema().expect("rule validates"); + } +} + +#[test] +fn v1_fixture_upgrades_to_v2_rule() { + let doc = fixture("v1_legacy_rule.json"); + let rule: Rule = serde_json::from_value(doc).expect("v1 compat parse"); + assert_eq!(rule.schema_version, 2); + assert!(!rule.conditions.leaves().is_empty()); + assert!(!rule.events.is_empty()); +} diff --git a/crates/dc_rulekit_demo_plugins/src/lib.rs b/crates/dc_rulekit_demo_plugins/src/lib.rs index cb33619..7f83824 100644 --- a/crates/dc_rulekit_demo_plugins/src/lib.rs +++ b/crates/dc_rulekit_demo_plugins/src/lib.rs @@ -41,6 +41,17 @@ impl ConditionEvaluator for WhenThreshold { .unwrap_or(0.0); Ok(actual >= min) } + fn params_schema(&self) -> Option { + Some(json!({ + "type": "object", + "properties": { + "key": { "type": "string" }, + "min": { "type": "number" } + }, + "required": ["min"], + "additionalProperties": false + })) + } } /// Passes when counter fact >= params.threshold (alias for threshold with `counter` key default). @@ -86,6 +97,15 @@ impl ActionHandler for ThenLog { eprintln!("[dc_rulekit-demo] {}", message); Ok(json!({ "logged": message })) } + fn params_schema(&self) -> Option { + Some(json!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "additionalProperties": false + })) + } } /// Pure planner: returns increment instruction (host may apply); does not mutate ctx. @@ -120,8 +140,8 @@ pub fn register_all(registry: &mut dc_rulekit::PluginRegistry) { /// End-to-end quickstart: propose static rule → approve → evaluate → audit. pub fn run_quickstart_example() -> Result { use dc_rulekit::{ - Action, AuditHook, Condition, Engine, EvaluateOptions, ProposalStore, Rule, RuleSource, - RuleStore, + AuditHook, Condition, ConditionNode, Engine, EvaluateOptions, ProposalStore, Rule, + RuleEvent, RuleSource, RuleStore, }; use std::sync::{Arc, Mutex}; @@ -137,25 +157,25 @@ pub fn run_quickstart_example() -> Result { let engine = Engine::new(®istry); let mut rule = Rule::new("demo.app/quickstart", "Quickstart", RuleSource::Static); - rule.when.push(Condition { + rule.conditions = ConditionNode::all(vec![ConditionNode::leaf(Condition { id: "always".into(), plugin: PLUGIN_ALWAYS.into(), params: json!({}), - }); - rule.then.push(Action { + })]); + rule.events.push(RuleEvent { id: "log".into(), - plugin: PLUGIN_LOG.into(), + event_type: PLUGIN_LOG.into(), params: json!({ "message": "approved rule fired" }), }); let mut proposals = ProposalStore::in_memory(); let mut active = RuleStore::in_memory(); - let proposal = proposals.propose(rule)?; + let proposal = proposals.propose(rule, ®istry)?; let active_rule = proposals.approve(&proposal.proposal_id, &mut active)?; let hook_storage = Arc::new(Mutex::new(Vec::new())); let hook = CaptureHook(Arc::clone(&hook_storage)); - let opaque = json!({ "example": "quickstart", "version": 1 }); + let opaque = json!({ "example": "quickstart", "version": 2 }); let receipt = engine.evaluate_with_audit( &active_rule, diff --git a/docs/ADR/0001-agnostic-core-vs-host-plugins.md b/docs/ADR/0001-agnostic-core-vs-host-plugins.md index 9cf29a5..f67f335 100644 --- a/docs/ADR/0001-agnostic-core-vs-host-plugins.md +++ b/docs/ADR/0001-agnostic-core-vs-host-plugins.md @@ -2,7 +2,7 @@ ## Status -Accepted (v0.1.0) +Accepted (v0.1.0). Rule **document** shape for v0.2+ is specified in [ADR 0002](./0002-industry-standard-rule-documents.md). ## Context diff --git a/docs/ADR/0002-industry-standard-rule-documents.md b/docs/ADR/0002-industry-standard-rule-documents.md new file mode 100644 index 0000000..0e76fbe --- /dev/null +++ b/docs/ADR/0002-industry-standard-rule-documents.md @@ -0,0 +1,50 @@ +# ADR 0002: Industry-standard rule document shapes + +## Status + +Accepted (2026-10-02) + +## Context + +`dc_rulekit` v0.1 used a flat `when` array and action objects with a `plugin` field. That shape is easy to implement but unfamiliar to LLMs and humans who already know [json-rules-engine](https://github.com/CacheControl/json-rules-engine) and [JSON Logic](https://jsonlogic.com/) conventions. + +Hosts (including Cruftkit) pin `dc_rulekit` `^0.1.0`; we must ship v0.2 without removing v0.1 from registries. + +## Decision + +**Rule JSON v2 (`schema_version: 2`)** aligns with industry patterns: + +| Concept | v0.1 | v0.2 (standard-aligned) | +|--------|------|-------------------------| +| Conditions | Flat `when[]` | Nested tree under `conditions` with `all` / `any` / `not` | +| Condition leaf | `{ id, plugin, params }` | Same; `fact` accepted as alias for `plugin` on ingest | +| Actions | `then[]` with `plugin` | `events[]` with `{ id, type, params }` (json-rules-engine event shape) | +| Context | `EvalContext.facts` + optional `event` | Unchanged — plain JSON bags | + +**Unchanged differentiators** (not part of the rule doc standard): + +- Proposal → approve → active lifecycle +- Dry-run and pure vs impure actions +- Fail-closed plugin registry +- Opaque audit receipts + +**Compatibility** + +- Deserialize accepts `schema_version` 1 or 2; v1 documents normalize to v2 in memory (`when` → `conditions.all`, `then` → `events` with `type` ← `plugin`). +- New writes and `validate_schema()` require v2. +- Optional JSON Schema on plugins; params validated at **propose** time when a schema is registered. + +**Future** + +- Condition nodes may gain a JSON Logic operator object (`{ "==": [...] }`) alongside plugin leaves without breaking v2 trees. + +## Consequences + +- Breaking Rust/Dart API: `Rule.when` / `Rule.then` → `Rule.conditions` / `Rule.events`; `propose(rule, registry)` validates params. +- Cruftkit on `^0.1.0` is unaffected until it opts into `0.2`. +- `schema/rule.schema.json` and golden fixtures guard Rust/Dart parity in CI. + +## References + +- [json-rules-engine conditions](https://github.com/CacheControl/json-rules-engine/blob/master/docs/rules.md) +- ADR 0001 — agnostic core vs host plugins diff --git a/packages/dc_rulekit/CHANGELOG.md b/packages/dc_rulekit/CHANGELOG.md index 8bc199e..7f08395 100644 --- a/packages/dc_rulekit/CHANGELOG.md +++ b/packages/dc_rulekit/CHANGELOG.md @@ -1,13 +1,11 @@ # Changelog -All notable changes to this project will be documented in this file. +## [0.2.0] - 2026-10-02 -## [0.1.0] - 2026-10-02 +- Rule JSON v2: `conditions` tree + `events` with `type`/`params`; v0.1 `when`/`then` still parses. +- `propose(rule, registry)` with optional plugin `paramsSchema` validation. +- See repository root [CHANGELOG.md](../../CHANGELOG.md). -### Added +## [0.1.0] - 2026-10-02 -- Rust crate **`dc_rulekit`**: rule model, plugin registry, in-memory/file stores, proposal workflow, engine with dry-run, audit hook. -- Dart package **`dc_rulekit`**: pure-Dart mirror of core semantics for Flutter hosts. -- Toy plugins in **`dc_rulekit_demo_plugins`** (`demo.when.*`, `demo.then.*`). -- ADR 0001 (agnostic core vs host plugins). -- Unit tests for Rust and Dart. +- Initial Dart mirror of dc_rulekit core. diff --git a/packages/dc_rulekit/lib/dc_rulekit.dart b/packages/dc_rulekit/lib/dc_rulekit.dart index c5de9bd..766ada3 100644 --- a/packages/dc_rulekit/lib/dc_rulekit.dart +++ b/packages/dc_rulekit/lib/dc_rulekit.dart @@ -1,4 +1,5 @@ export 'src/audit.dart'; +export 'src/conditions.dart'; export 'src/context.dart'; export 'src/engine.dart'; export 'src/error.dart'; diff --git a/packages/dc_rulekit/lib/src/conditions.dart b/packages/dc_rulekit/lib/src/conditions.dart new file mode 100644 index 0000000..4f73a97 --- /dev/null +++ b/packages/dc_rulekit/lib/src/conditions.dart @@ -0,0 +1,163 @@ +import 'error.dart'; + +const readableSchemaVersions = [1, 2]; + +class Condition { + Condition({required this.id, required this.plugin, this.params = const {}}); + final String id; + final String plugin; + final Map params; + + Map toJson() => { + 'id': id, + 'plugin': plugin, + 'params': params, + }; + + factory Condition.fromJson(Map json) => Condition( + id: json['id'] as String, + plugin: (json['plugin'] ?? json['fact']) as String, + params: Map.from(json['params'] as Map? ?? {}), + ); +} + +sealed class ConditionNode { + const ConditionNode(); + + factory ConditionNode.all(List nodes) = AllConditionNode; + factory ConditionNode.any(List nodes) = AnyConditionNode; + factory ConditionNode.not(ConditionNode inner) = NotConditionNode; + factory ConditionNode.leaf(Condition condition) = LeafConditionNode; + + List leaves() { + final out = []; + collectLeaves(out); + return out; + } + + void collectLeaves(List out); + + void validateShape() { + switch (this) { + case AllConditionNode(:final all): + for (final child in all) { + child.validateShape(); + } + case AnyConditionNode(:final any): + for (final child in any) { + child.validateShape(); + } + case NotConditionNode(:final not): + not.validateShape(); + case LeafConditionNode(:final condition): + if (condition.id.isEmpty || condition.plugin.isEmpty) { + throw EvaluationError('condition leaf requires non-empty id and plugin'); + } + } + } + + Map toJson() => switch (this) { + AllConditionNode(:final all) => { + 'all': all.map((c) => c.toJson()).toList(), + }, + AnyConditionNode(:final any) => { + 'any': any.map((c) => c.toJson()).toList(), + }, + NotConditionNode(:final not) => { + 'not': not.toJson(), + }, + LeafConditionNode(:final condition) => condition.toJson(), + }; + + static ConditionNode fromJson(dynamic json) { + if (json is! Map) { + throw EvaluationError('invalid condition node'); + } + final map = Map.from(json); + if (map.containsKey('all')) { + return AllConditionNode( + (map['all'] as List) + .map((e) => ConditionNode.fromJson(e)) + .toList(), + ); + } + if (map.containsKey('any')) { + return AnyConditionNode( + (map['any'] as List) + .map((e) => ConditionNode.fromJson(e)) + .toList(), + ); + } + if (map.containsKey('not')) { + return NotConditionNode(ConditionNode.fromJson(map['not'])); + } + return LeafConditionNode(Condition.fromJson(map)); + } +} + +final class AllConditionNode extends ConditionNode { + AllConditionNode(this.all); + final List all; + + @override + void collectLeaves(List out) { + for (final c in all) { + c.collectLeaves(out); + } + } +} + +final class AnyConditionNode extends ConditionNode { + AnyConditionNode(this.any); + final List any; + + @override + void collectLeaves(List out) { + for (final c in any) { + c.collectLeaves(out); + } + } +} + +final class NotConditionNode extends ConditionNode { + NotConditionNode(this.not); + final ConditionNode not; + + @override + void collectLeaves(List out) => not.collectLeaves(out); +} + +final class LeafConditionNode extends ConditionNode { + LeafConditionNode(this.condition); + final Condition condition; + + @override + void collectLeaves(List out) => out.add(condition); +} + +class RuleEvent { + RuleEvent({required this.id, required this.eventType, this.params = const {}}); + final String id; + final String eventType; + final Map params; + + String get pluginId => eventType; + + Map toJson() => { + 'id': id, + 'type': eventType, + 'params': params, + }; + + factory RuleEvent.fromJson(Map json) => RuleEvent( + id: json['id'] as String, + eventType: (json['type'] ?? json['plugin']) as String, + params: Map.from(json['params'] as Map? ?? {}), + ); +} + +typedef Action = RuleEvent; + +ConditionNode conditionsFromV1When(List when) => ConditionNode.all( + when.map(ConditionNode.leaf).toList(), + ); diff --git a/packages/dc_rulekit/lib/src/engine.dart b/packages/dc_rulekit/lib/src/engine.dart index 6464525..dfbb743 100644 --- a/packages/dc_rulekit/lib/src/engine.dart +++ b/packages/dc_rulekit/lib/src/engine.dart @@ -1,4 +1,5 @@ import 'audit.dart'; +import 'conditions.dart'; import 'context.dart'; import 'error.dart'; import 'model.dart'; @@ -19,30 +20,16 @@ class Engine { registry.validateRulePlugins(rule); final conditionResults = []; - var allPassed = true; - - for (final condition in rule.when) { - final evaluator = registry.getCondition(condition.plugin); - final passed = evaluator.evaluate(condition.params, ctx); - conditionResults.add(ConditionOutcome( - conditionId: condition.id, - plugin: condition.plugin, - passed: passed, - )); - if (!passed) { - allPassed = false; - break; - } - } + final allPassed = _evaluateConditions(rule.conditions, ctx, conditionResults, failFast: true); final actionOutcomes = []; if (allPassed) { - for (final action in rule.then) { - final handler = registry.getAction(action.plugin); + for (final action in rule.events) { + final handler = registry.getAction(action.pluginId); if (options.dryRun && !handler.isPure) { actionOutcomes.add(ActionOutcome( actionId: action.id, - plugin: action.plugin, + eventType: action.eventType, executed: false, skippedDryRun: true, )); @@ -51,7 +38,7 @@ class Engine { final result = handler.execute(action.params, ctx); actionOutcomes.add(ActionOutcome( actionId: action.id, - plugin: action.plugin, + eventType: action.eventType, executed: true, skippedDryRun: false, result: result, @@ -68,6 +55,46 @@ class Engine { ); } + bool _evaluateConditions( + ConditionNode node, + EvalContext ctx, + List outcomes, { + required bool failFast, + }) { + switch (node) { + case AllConditionNode(:final all): + if (all.isEmpty) return true; + for (final child in all) { + if (!_evaluateConditions(child, ctx, outcomes, failFast: failFast)) { + return false; + } + } + return true; + case AnyConditionNode(:final any): + if (any.isEmpty) return false; + for (final child in any) { + final branch = []; + if (_evaluateConditions(child, ctx, branch, failFast: false)) { + outcomes.addAll(branch); + return true; + } + outcomes.addAll(branch); + } + return false; + case NotConditionNode(:final not): + return !_evaluateConditions(not, ctx, outcomes, failFast: false); + case LeafConditionNode(:final condition): + final evaluator = registry.getCondition(condition.plugin); + final passed = evaluator.evaluate(condition.params, ctx); + outcomes.add(ConditionOutcome( + conditionId: condition.id, + plugin: condition.plugin, + passed: passed, + )); + return passed; + } + } + AuditReceipt evaluateWithAudit( Rule rule, EvalContext ctx, diff --git a/packages/dc_rulekit/lib/src/error.dart b/packages/dc_rulekit/lib/src/error.dart index d4dfb25..7e7d737 100644 --- a/packages/dc_rulekit/lib/src/error.dart +++ b/packages/dc_rulekit/lib/src/error.dart @@ -74,3 +74,15 @@ class StoreError extends RulekitException { StoreError(this.detail) : super('store error: $detail'); final String detail; } + +class EvaluationError extends RulekitException { + EvaluationError(this.detail) : super('evaluation error: $detail'); + final String detail; +} + +class InvalidPluginParams extends RulekitException { + InvalidPluginParams(this.pluginId, this.detail) + : super('invalid plugin params: plugin=$pluginId, $detail'); + final String pluginId; + final String detail; +} diff --git a/packages/dc_rulekit/lib/src/model.dart b/packages/dc_rulekit/lib/src/model.dart index e07b1a4..6d106f8 100644 --- a/packages/dc_rulekit/lib/src/model.dart +++ b/packages/dc_rulekit/lib/src/model.dart @@ -1,6 +1,7 @@ +import 'conditions.dart'; import 'error.dart'; -const currentSchemaVersion = 1; +const currentSchemaVersion = 2; enum RuleSource { static, llm } @@ -35,44 +36,6 @@ class Trigger { } } -class Condition { - Condition({required this.id, required this.plugin, this.params = const {}}); - final String id; - final String plugin; - final Map params; - - Map toJson() => { - 'id': id, - 'plugin': plugin, - 'params': params, - }; - - factory Condition.fromJson(Map json) => Condition( - id: json['id'] as String, - plugin: json['plugin'] as String, - params: Map.from(json['params'] as Map? ?? {}), - ); -} - -class Action { - Action({required this.id, required this.plugin, this.params = const {}}); - final String id; - final String plugin; - final Map params; - - Map toJson() => { - 'id': id, - 'plugin': plugin, - 'params': params, - }; - - factory Action.fromJson(Map json) => Action( - id: json['id'] as String, - plugin: json['plugin'] as String, - params: Map.from(json['params'] as Map? ?? {}), - ); -} - class Rule { Rule({ required this.id, @@ -81,9 +44,9 @@ class Rule { this.schemaVersion = currentSchemaVersion, this.enabled = true, this.trigger = const Trigger.manual(), - this.when = const [], - this.then = const [], - }); + ConditionNode? conditions, + this.events = const [], + }) : conditions = conditions ?? ConditionNode.all(const []); final int schemaVersion; final String id; @@ -91,8 +54,8 @@ class Rule { final RuleSource source; final bool enabled; final Trigger trigger; - final List when; - final List then; + final ConditionNode conditions; + final List events; void validateSchema() { if (schemaVersion != currentSchemaVersion) { @@ -101,6 +64,12 @@ class Rule { found: schemaVersion, ); } + conditions.validateShape(); + for (final ev in events) { + if (ev.id.isEmpty || ev.eventType.isEmpty) { + throw EvaluationError('event requires non-empty id and type'); + } + } } Map toJson() => { @@ -110,26 +79,50 @@ class Rule { 'source': source.name, 'enabled': enabled, 'trigger': trigger.toJson(), - 'when': when.map((c) => c.toJson()).toList(), - 'then': then.map((a) => a.toJson()).toList(), + 'conditions': conditions.toJson(), + 'events': events.map((e) => e.toJson()).toList(), }; - factory Rule.fromJson(Map json) => Rule( - schemaVersion: json['schema_version'] as int? ?? currentSchemaVersion, - id: json['id'] as String, - title: json['title'] as String, - source: RuleSource.values.byName(json['source'] as String? ?? 'static'), - enabled: json['enabled'] as bool? ?? true, - trigger: Trigger.fromJson( - Map.from(json['trigger'] as Map? ?? {'type': 'manual'}), - ), - when: (json['when'] as List? ?? []) - .map((e) => Condition.fromJson(Map.from(e as Map))) - .toList(), - then: (json['then'] as List? ?? []) - .map((e) => Action.fromJson(Map.from(e as Map))) - .toList(), - ); + factory Rule.fromJson(Map json) { + final version = json['schema_version'] as int? ?? currentSchemaVersion; + if (!readableSchemaVersions.contains(version)) { + throw SchemaVersionMismatch(expected: currentSchemaVersion, found: version); + } + + ConditionNode conditions; + if (json['conditions'] != null) { + conditions = ConditionNode.fromJson(json['conditions']); + } else { + final when = (json['when'] as List? ?? []) + .map((e) => Condition.fromJson(Map.from(e as Map))) + .toList(); + conditions = conditionsFromV1When(when); + } + + List events; + if ((json['events'] as List?)?.isNotEmpty ?? false) { + events = (json['events'] as List) + .map((e) => RuleEvent.fromJson(Map.from(e as Map))) + .toList(); + } else { + events = (json['then'] as List? ?? []) + .map((e) => RuleEvent.fromJson(Map.from(e as Map))) + .toList(); + } + + return Rule( + schemaVersion: currentSchemaVersion, + id: json['id'] as String, + title: json['title'] as String, + source: RuleSource.values.byName(json['source'] as String? ?? 'static'), + enabled: json['enabled'] as bool? ?? true, + trigger: Trigger.fromJson( + Map.from(json['trigger'] as Map? ?? {'type': 'manual'}), + ), + conditions: conditions, + events: events, + ); + } } enum ProposalStatus { proposed, approved, rejected } @@ -185,20 +178,20 @@ class ConditionOutcome { class ActionOutcome { ActionOutcome({ required this.actionId, - required this.plugin, + required this.eventType, required this.executed, required this.skippedDryRun, this.result, }); final String actionId; - final String plugin; + final String eventType; final bool executed; final bool skippedDryRun; final Map? result; Map toJson() => { 'action_id': actionId, - 'plugin': plugin, + 'type': eventType, 'executed': executed, 'skipped_dry_run': skippedDryRun, if (result != null) 'result': result, diff --git a/packages/dc_rulekit/lib/src/plugin.dart b/packages/dc_rulekit/lib/src/plugin.dart index 3c35923..81803fa 100644 --- a/packages/dc_rulekit/lib/src/plugin.dart +++ b/packages/dc_rulekit/lib/src/plugin.dart @@ -1,6 +1,7 @@ import 'error.dart'; import 'model.dart'; import 'context.dart'; +import 'schema_validate.dart'; abstract class ConditionEvaluator { String get pluginId; @@ -13,6 +14,16 @@ abstract class ActionHandler { Map execute(Map params, EvalContext ctx); } +/// Optional JSON Schema for plugin params (implement on evaluators/handlers that need propose-time validation). +abstract interface class ParamsSchemaProvider { + Map? get paramsSchema; +} + +Map? paramsSchemaFor(Object plugin) { + if (plugin is ParamsSchemaProvider) return plugin.paramsSchema; + return null; +} + class PluginRegistry { final _conditions = {}; final _actions = {}; @@ -38,11 +49,28 @@ class PluginRegistry { } void validateRulePlugins(Rule rule) { - for (final c in rule.when) { + for (final c in rule.conditions.leaves()) { getCondition(c.plugin); } - for (final a in rule.then) { - getAction(a.plugin); + for (final a in rule.events) { + getAction(a.pluginId); + } + } + + void validateRuleParams(Rule rule) { + for (final c in rule.conditions.leaves()) { + final evaluator = getCondition(c.plugin); + final schema = paramsSchemaFor(evaluator); + if (schema != null) { + validateParamsAgainstSchema(c.plugin, c.params, schema); + } + } + for (final a in rule.events) { + final handler = getAction(a.pluginId); + final schema = paramsSchemaFor(handler); + if (schema != null) { + validateParamsAgainstSchema(a.pluginId, a.params, schema); + } } } } diff --git a/packages/dc_rulekit/lib/src/proposal.dart b/packages/dc_rulekit/lib/src/proposal.dart index 6691fd2..150ca9d 100644 --- a/packages/dc_rulekit/lib/src/proposal.dart +++ b/packages/dc_rulekit/lib/src/proposal.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'error.dart'; import 'model.dart'; +import 'plugin.dart'; import 'store.dart'; class ProposalStore { @@ -11,8 +12,10 @@ class ProposalStore { final String? filePath; final _proposals = {}; - RuleProposal propose(Rule rule) { + RuleProposal propose(Rule rule, PluginRegistry registry) { rule.validateSchema(); + registry.validateRulePlugins(rule); + registry.validateRuleParams(rule); final proposal = RuleProposal( proposalId: _uuid(), status: ProposalStatus.proposed, diff --git a/packages/dc_rulekit/lib/src/schema_validate.dart b/packages/dc_rulekit/lib/src/schema_validate.dart new file mode 100644 index 0000000..f6c1d37 --- /dev/null +++ b/packages/dc_rulekit/lib/src/schema_validate.dart @@ -0,0 +1,51 @@ +import 'error.dart'; + +/// Minimal draft-07 subset: object type, required keys, property types, additionalProperties. +void validateParamsAgainstSchema( + String pluginId, + Map params, + Map schema, +) { + if (schema['type'] == 'object') { + if (schema['additionalProperties'] == false) { + final allowed = { + ...((schema['properties'] as Map?)?.keys.map((k) => k.toString()) ?? []), + }; + for (final key in params.keys) { + if (!allowed.contains(key)) { + throw InvalidPluginParams(pluginId, 'additional property not allowed: $key'); + } + } + } + for (final req in (schema['required'] as List? ?? [])) { + if (!params.containsKey(req)) { + throw InvalidPluginParams(pluginId, 'missing required property: $req'); + } + } + final props = schema['properties'] as Map? ?? {}; + for (final entry in params.entries) { + final propSchema = props[entry.key]; + if (propSchema is Map) { + _validateValue(pluginId, entry.key, entry.value, Map.from(propSchema)); + } + } + } +} + +void _validateValue( + String pluginId, + String path, + Object? value, + Map schema, +) { + final t = schema['type']; + if (t == 'string' && value is! String) { + throw InvalidPluginParams(pluginId, '$path must be string'); + } + if (t == 'number' && value is! num) { + throw InvalidPluginParams(pluginId, '$path must be number'); + } + if (t == 'boolean' && value is! bool) { + throw InvalidPluginParams(pluginId, '$path must be boolean'); + } +} diff --git a/packages/dc_rulekit/pubspec.yaml b/packages/dc_rulekit/pubspec.yaml index 1a77787..3b72b4f 100644 --- a/packages/dc_rulekit/pubspec.yaml +++ b/packages/dc_rulekit/pubspec.yaml @@ -1,6 +1,6 @@ name: dc_rulekit description: Business-agnostic on-device rules engine (Dart mirror of the Rust dc_rulekit core). -version: 0.1.0 +version: 0.2.0 repository: https://github.com/DevelopersCoffee/rulekit environment: diff --git a/packages/dc_rulekit/test/engine_test.dart b/packages/dc_rulekit/test/engine_test.dart index d78c804..b31dda0 100644 --- a/packages/dc_rulekit/test/engine_test.dart +++ b/packages/dc_rulekit/test/engine_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:dc_rulekit/dc_rulekit.dart'; import 'package:test/test.dart'; @@ -8,6 +11,20 @@ class AlwaysCondition implements ConditionEvaluator { bool evaluate(Map params, EvalContext ctx) => true; } +class SchemaCondition implements ConditionEvaluator, ParamsSchemaProvider { + @override + String get pluginId => 'demo.when.schema'; + @override + bool evaluate(Map params, EvalContext ctx) => true; + @override + Map? get paramsSchema => { + 'type': 'object', + 'required': ['min'], + 'properties': {'min': {'type': 'number'}}, + 'additionalProperties': false, + }; +} + class LogAction implements ActionHandler { LogAction(this.log); final List log; @@ -31,28 +48,40 @@ class PureEcho implements ActionHandler { Map execute(Map params, EvalContext ctx) => params; } -void main() { - PluginRegistry registry() { - final reg = PluginRegistry(); - reg.registerCondition(AlwaysCondition()); - reg.registerAction(LogAction([])); - reg.registerAction(PureEcho()); - return reg; - } +class _CaptureHook implements AuditHook { + _CaptureHook(this._fn); + final void Function(AuditReceipt) _fn; + @override + void onReceipt(AuditReceipt receipt) => _fn(receipt); +} +PluginRegistry demoRegistry({List? log}) { + final reg = PluginRegistry(); + reg.registerCondition(AlwaysCondition()); + reg.registerCondition(SchemaCondition()); + reg.registerAction(LogAction(log ?? [])); + reg.registerAction(PureEcho()); + return reg; +} + +void main() { test('evaluate runs actions when conditions pass', () { final log = []; - final reg = PluginRegistry(); - reg.registerCondition(AlwaysCondition()); - reg.registerAction(LogAction(log)); + final reg = demoRegistry(log: log); final engine = Engine(reg); final rule = Rule( id: 'demo.app/rule-1', title: 'Test', source: RuleSource.static, - when: [Condition(id: 'c1', plugin: 'demo.when.always')], - then: [ - Action(id: 'a1', plugin: 'demo.then.log', params: {'message': 'hello'}), + conditions: ConditionNode.all([ + ConditionNode.leaf(Condition(id: 'c1', plugin: 'demo.when.always')), + ]), + events: [ + RuleEvent( + id: 'a1', + eventType: 'demo.then.log', + params: {'message': 'hello'}, + ), ], ); final receipt = engine.evaluate(rule, EvalContext(appNamespace: 'demo.app')); @@ -62,16 +91,15 @@ void main() { test('dry-run skips impure actions', () { final log = []; - final reg = PluginRegistry(); - reg.registerCondition(AlwaysCondition()); - reg.registerAction(LogAction(log)); - final engine = Engine(reg); + final engine = Engine(demoRegistry(log: log)); final rule = Rule( id: 'demo.app/rule-2', title: 'Dry', source: RuleSource.static, - when: [Condition(id: 'c1', plugin: 'demo.when.always')], - then: [Action(id: 'a1', plugin: 'demo.then.log')], + conditions: ConditionNode.all([ + ConditionNode.leaf(Condition(id: 'c1', plugin: 'demo.when.always')), + ]), + events: [RuleEvent(id: 'a1', eventType: 'demo.then.log')], ); final receipt = engine.evaluate( rule, @@ -83,12 +111,14 @@ void main() { }); test('unknown plugin fail-closed', () { - final engine = Engine(registry()); + final engine = Engine(demoRegistry()); final rule = Rule( id: 'demo.app/bad', title: 'Bad', source: RuleSource.static, - when: [Condition(id: 'c1', plugin: 'missing.plugin')], + conditions: ConditionNode.all([ + ConditionNode.leaf(Condition(id: 'c1', plugin: 'missing.plugin')), + ]), ); expect( () => engine.evaluate(rule, EvalContext(appNamespace: 'demo.app')), @@ -97,6 +127,7 @@ void main() { }); test('proposal approve lifecycle', () { + final reg = demoRegistry(); final proposals = ProposalStore(); final active = RuleStore(); final rule = Rule( @@ -104,40 +135,80 @@ void main() { title: 'Prop', source: RuleSource.static, ); - final proposal = proposals.propose(rule); + final proposal = proposals.propose(rule, reg); expect(proposal.status, ProposalStatus.proposed); proposals.approve(proposal.proposalId, active); expect(active.get('demo.app/prop').title, 'Prop'); }); test('audit hook receives opaque payload', () { - final reg = PluginRegistry(); - reg.registerCondition(AlwaysCondition()); - reg.registerAction(PureEcho()); + final reg = demoRegistry(); final engine = Engine(reg); final rule = Rule( id: 'demo.app/audit', title: 'Audit', source: RuleSource.static, - when: [Condition(id: 'c1', plugin: 'demo.when.always')], - then: [Action(id: 'a1', plugin: 'demo.then.echo')], + conditions: ConditionNode.all([ + ConditionNode.leaf(Condition(id: 'c1', plugin: 'demo.when.always')), + ]), + events: [RuleEvent(id: 'a1', eventType: 'demo.then.echo')], ); AuditReceipt? captured; - final hook = _CaptureHook((r) => captured = r); engine.evaluateWithAudit( rule, EvalContext(appNamespace: 'demo.app'), const EvaluateOptions(), - hook, + _CaptureHook((r) => captured = r), {'host': 'payload'}, ); expect(captured!.opaque, {'host': 'payload'}); }); + + test('v1 json compat', () { + final log = []; + final engine = Engine(demoRegistry(log: log)); + final v1 = jsonDecode(''' +{ + "schema_version": 1, + "id": "demo.app/v1", + "title": "Legacy", + "source": "static", + "when": [{"id": "c1", "plugin": "demo.when.always", "params": {}}], + "then": [{"id": "a1", "plugin": "demo.then.log", "params": {"message": "legacy"}}] } +''') as Map; + final rule = Rule.fromJson(v1); + expect(rule.schemaVersion, 2); + engine.evaluate(rule, EvalContext(appNamespace: 'demo.app')); + expect(log, ['legacy']); + }); -class _CaptureHook implements AuditHook { - _CaptureHook(this._fn); - final void Function(AuditReceipt) _fn; - @override - void onReceipt(AuditReceipt receipt) => _fn(receipt); + test('golden v2 fixture roundtrip', () { + final path = '../../schema/fixtures/v2_basic_rule.json'; + final text = File(path).readAsStringSync(); + final rule = Rule.fromJson(jsonDecode(text) as Map); + expect(rule.events.first.eventType, 'demo.then.log'); + expect(rule.conditions.leaves().first.plugin, 'demo.when.threshold'); + }); + + test('propose validates params schema', () { + final reg = demoRegistry(); + final proposals = ProposalStore(); + final rule = Rule( + id: 'demo.app/bad-schema', + title: 'Bad', + source: RuleSource.static, + conditions: ConditionNode.all([ + ConditionNode.leaf(Condition( + id: 'c1', + plugin: 'demo.when.schema', + params: {'wrong': true}, + )), + ]), + ); + expect( + () => proposals.propose(rule, reg), + throwsA(isA()), + ); + }); } diff --git a/schema/fixtures/v1_legacy_rule.json b/schema/fixtures/v1_legacy_rule.json new file mode 100644 index 0000000..b0f82cf --- /dev/null +++ b/schema/fixtures/v1_legacy_rule.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "id": "demo.app/legacy", + "title": "v0.1 flat when/then", + "source": "static", + "when": [ + { + "id": "c1", + "plugin": "demo.when.always", + "params": {} + } + ], + "then": [ + { + "id": "a1", + "plugin": "demo.then.log", + "params": { "message": "legacy" } + } + ] +} diff --git a/schema/fixtures/v2_basic_rule.json b/schema/fixtures/v2_basic_rule.json new file mode 100644 index 0000000..22a2058 --- /dev/null +++ b/schema/fixtures/v2_basic_rule.json @@ -0,0 +1,24 @@ +{ + "schema_version": 2, + "id": "demo.app/hello", + "title": "Hello when value is high enough", + "source": "llm", + "enabled": true, + "trigger": { "type": "event", "topic": "facts.updated" }, + "conditions": { + "all": [ + { + "id": "threshold", + "plugin": "demo.when.threshold", + "params": { "key": "value", "min": 10 } + } + ] + }, + "events": [ + { + "id": "log", + "type": "demo.then.log", + "params": { "message": "threshold met" } + } + ] +} diff --git a/schema/fixtures/v2_nested_conditions.json b/schema/fixtures/v2_nested_conditions.json new file mode 100644 index 0000000..53387e1 --- /dev/null +++ b/schema/fixtures/v2_nested_conditions.json @@ -0,0 +1,33 @@ +{ + "schema_version": 2, + "id": "demo.app/nested", + "title": "Nested any/all", + "source": "static", + "conditions": { + "all": [ + { + "any": [ + { + "id": "a", + "plugin": "demo.when.always", + "params": {} + }, + { + "not": { + "id": "b", + "plugin": "demo.when.never", + "params": {} + } + } + ] + } + ] + }, + "events": [ + { + "id": "inc", + "type": "demo.then.increment_counter", + "params": { "key": "counter", "by": 1 } + } + ] +} diff --git a/schema/rule.schema.json b/schema/rule.schema.json new file mode 100644 index 0000000..2247973 --- /dev/null +++ b/schema/rule.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://developers.coffee/schemas/dc_rulekit/rule.schema.json", + "title": "dc_rulekit Rule (v2)", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "id", "title", "source", "conditions", "events"], + "properties": { + "schema_version": { "type": "integer", "const": 2 }, + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "source": { "type": "string", "enum": ["static", "llm"] }, + "enabled": { "type": "boolean", "default": true }, + "trigger": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "type": "string", "enum": ["manual", "event", "schedule"] }, + "topic": { "type": "string" }, + "expression": { "type": "string" } + }, + "additionalProperties": false + }, + "conditions": { "$ref": "#/$defs/conditionNode" }, + "events": { + "type": "array", + "items": { "$ref": "#/$defs/event" } + } + }, + "$defs": { + "conditionNode": { + "oneOf": [ + { + "type": "object", + "required": ["all"], + "additionalProperties": false, + "properties": { + "all": { + "type": "array", + "items": { "$ref": "#/$defs/conditionNode" } + } + } + }, + { + "type": "object", + "required": ["any"], + "additionalProperties": false, + "properties": { + "any": { + "type": "array", + "items": { "$ref": "#/$defs/conditionNode" } + } + } + }, + { + "type": "object", + "required": ["not"], + "additionalProperties": false, + "properties": { + "not": { "$ref": "#/$defs/conditionNode" } + } + }, + { "$ref": "#/$defs/conditionLeaf" } + ] + }, + "conditionLeaf": { + "type": "object", + "required": ["id", "plugin"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "plugin": { "type": "string", "minLength": 1 }, + "fact": { "type": "string", "minLength": 1 }, + "params": { "type": "object" } + }, + "anyOf": [{ "required": ["plugin"] }, { "required": ["fact"] }] + }, + "event": { + "type": "object", + "required": ["id", "type"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "type": { "type": "string", "minLength": 1 }, + "params": { "type": "object" } + } + } + } +}