What it does
The rules engine is a per-workspace governance gate that runs before any decision is written to
the graph. When a decision reaches emit_decision_trace(...), an attached gate projects the edge into
a flat set of fields, evaluates the workspace's rules against it, and returns an outcome plus an audit record.
Rules are authored in a small DSL. They match structured fields (numbers, dates, channels) with exact operators,
and free-text fields (like the relationship type) by meaning — via a semantic sim() predicate
backed by a local embedding model. Three outcomes are possible:
needs_reviewbusiness_rule_engine library's parser and adds what a multi-tenant decision graph
needs: workspace isolation, semantic matching, fail-open evaluation, audit attribution, persistence, an HTTP API,
and a natural-language author agent.
The rule DSL
A rule is a named, prioritised when … then block. Conditions are Python boolean expressions over the
decision's fields; actions declare the outcome with a human-readable reason.
rule "large discount needs finance review" priority 10
when
sim(relation_type, "APPROVAL") > 0.4
and percent > 0.15
and approved_via == "slack"
then
flag("Discount >15% approved over Slack — route to Finance for review")
end
Actions
| Verb | Outcome | Effect |
|---|---|---|
reject("…") | REJECT | Block the write — the emit raises and nothing is persisted (callers map to HTTP 422). |
flag("…") | FLAG | Persist the edge but annotate it needs_review with the audit attached. |
notify("…") | note | Record a side note; does not change the gate outcome. |
There is no block/deny verb — the blocking action is reject.
Available operators: ==, >, <, >=, <=, and
boolean and/or.
when and then must each sit alone on their own line, with conditions and actions indented
beneath. The loader lints for the common mistake of putting a condition on the same line as when (which
the underlying parser would silently swallow) and rejects it.
The fields you match on
Before evaluation, a decision (src, tgt, relation_type, RelationContext) is projected into a
flat dict. Some fields pass through raw; a few are derived.
| Field | Type | Source |
|---|---|---|
src, tgt, relation_type | text | Head/tail names & relationship keyword — match with sim(). |
approved_via | text | Channel: slack|zoom|email|in_person|jira|system — match with ==. |
approved_by, policy_ref, provenance | text | Raw RelationContext fields. |
decision_trace, quantitative_data, temporal_info | text | Raw free-text (may be null). |
valid_from, valid_until | date | ISO-8601 validity window. |
confidence | float | Extraction confidence, 0–1. |
amount | float|null derived | Parsed from quantitative_data — "$25,000" → 25000.0. |
percent | float|null derived | Parsed from quantitative_data — "20%" → 0.20. |
is_active | bool derived | Whether the decision is inside its validity window today. |
evidence_count | int derived | Number of supporting sentences. |
amount and percent are best-effort parses of the free-text quantitative_data
field, not raw numbers. A rule with percent > 0.15 only fires when quantitative_data
actually contains something like "20% discount". If it can't be parsed, the value is null
and the rule is skipped fail-open (see below). This is exactly the raw-text fragility the
Ontology is designed to remove by handing the engine typed values.
Semantic matching
The relationship type is free text — an LLM might write "approved", "authorized", "gave the
go-ahead". Matching those with string equality is hopeless. sim(field, "CONCEPT") matches on meaning.
A concept maps a name to a list of example phrases. sim() returns the maximum cosine similarity
between the field's embedding and any of the concept's phrase embeddings — a float in [-1, 1] you compare
against a threshold. A companion similar(field, "CONCEPT", threshold=0.4) returns the boolean directly.
// concept catalog — name -> phrases
{
"APPROVAL": ["approved", "authorized", "granted approval", "go ahead"]
}
minishlab/potion-retrieval-32M — a static, ~30 MB, MIT-licensed model. No network call, no API
cost, repeatable scores. It loads lazily, only when a gate actually evaluates.0.4. Empirically,
non-matches ceil around 0.24 and true matches floor around 0.53 on the approval
concept — so 0.4 is a safe starting line to tune per concept.Field text is normalised (snake_case/kebab-case split, whitespace collapsed) and embeddings are L2-normalised so the dot product is the cosine. The model id is recorded in a fingerprint for audit and pinning.
The pre-emit gate
The gate sits inside emit_decision_trace, before any write. If the edge already exists and you're
upserting, the new context is merged with the existing one first, so the gate always evaluates the
final decision.
- REJECT raises a
RuleViolation— nothing is persisted. - FLAG persists the edge with
needs_review=trueand the JSON audit stored on the edge. - PASS persists unchanged.
warnings, never crashing the emit. A misconfigured rule can't take down your ingestion;
it just doesn't fire, visibly. The audit records outcome, model_id, the decisive
rule + reason, the matched_concept/score/threshold,
plus any notes and warnings.
The /rules API
All endpoints are workspace-scoped via LIGHTRAG-WORKSPACE and return 503 unless the
server runs in Context Graph mode (USE_CONTEXT_GRAPH=true).
| Method · Path | Purpose | Returns |
|---|---|---|
GET /rules | Policy summary | RuleSummaryResponse |
POST /rules | Save DSL + concepts | Summary; attaches the gate live (400 if invalid) |
POST /rules/evaluate | Dry-run against a sample decision | EvaluateResponse |
POST /rules/toggle | Enable/disable the gate | Summary (404 if none) |
DELETE /rules | Delete the policy | {deleted, workspace} |
POST /rules/generate | NL → policy (LLM) | Generation result + saved (503 if no LLM) |
The summary reports exists, enabled, version, model_id, the
concept names, and the rules with their priorities. evaluate returns the outcome, the
audit, and the triggered rules with their concept matches, warnings, and notes:
curl -X POST http://localhost:9621/rules/evaluate \
-H "LIGHTRAG-WORKSPACE: sales" -H "Content-Type: application/json" \
-d '{
"src":"Sarah Chen","tgt":"MegaCorp Order","relation_type":"approved a large discount",
"relation_context":{"quantitative_data":"20% discount","approved_via":"slack"}
}'
# -> outcome FLAG · matched_concept APPROVAL · score 0.53 > 0.4
Storage & lifecycle
A workspace's policy is a rule bundle — DSL, concepts, an enabled flag, the embedding
model_id, and a version — persisted as one atomic JSON file per workspace.
- Validated on save without loading the model: the DSL must parse with non-empty conditions, and every
concept referenced by a
sim()must be defined. Version bumps on each save. - Toggling enabled/disabled updates the timestamp without re-validating or bumping the version.
- Live wiring. A service caches one built gate per workspace, keyed by version, invalidated on save/toggle/delete. Saving a policy attaches the gate so the very next decision is governed. The embedding model loads lazily — summaries and saves stay fast and offline.