Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, &registry)` |

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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
89 changes: 69 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,25 +82,27 @@ register_all(&mut registry);
let engine = Engine::new(&registry);

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, &registry)?;
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);
```

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/dc_rulekit/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
122 changes: 122 additions & 0 deletions crates/dc_rulekit/src/conditions.rs
Original file line number Diff line number Diff line change
@@ -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<ConditionNode>,
},
Any {
any: Vec<ConditionNode>,
},
Not {
not: Box<ConditionNode>,
},
Leaf(Condition),
}

impl Default for ConditionNode {
fn default() -> Self {
Self::All { all: Vec::new() }
}
}

impl ConditionNode {
pub fn all(nodes: Vec<ConditionNode>) -> Self {
Self::All { all: nodes }
}

pub fn any(nodes: Vec<ConditionNode>) -> 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<Condition>) -> ConditionNode {
ConditionNode::All {
all: when.into_iter().map(ConditionNode::Leaf).collect(),
}
}
Loading
Loading