01 Executive summary
Two axes, one direction of travel.
The gap analysis found that Context Graph (CG) meets or exceeds the article on the
decision-memory / provenance / temporal axis — that is exactly what the 11-field
RelationContext (rc) was built for — and is solid on multi-hop retrieval (CGR3). What CG
lacks is the article’s other half: the ontology as an operational runtime — typed schema, executable actions,
constraint enforcement, inference, and object-level access control.
This document proposes closing that half in priority order. The headline move (P1) is a Business Rules Engine: it directly fills the largest, most concrete gaps — constraint enforcement and the first useful slice of an action layer — while staying close to CG’s grain. Because authoring rule logic by hand is the adoption bottleneck, we pair it with an AI agent that generates the rule DSL from natural-language policy, per use case. P2 (Ontology) then gives those rules a typed vocabulary to reference. P3 captures the remaining gaps so nothing is lost.
emit_decision_trace). A rules engine lets CG say
whether a decision is allowed — and, eventually, act on it. That is the jump from passive
memory to active policy.
02 Gap scorecard
Where we stand against the article’s claimed capabilities. have partial gap — and which priority addresses it.
| Article capability | CG today | Evidence / note | Addressed by |
|---|---|---|---|
| Decision-history tracking | exceeds | rc 11-field record, decisions_vdb — context_graph_types.py:16 | — |
| Precedent discovery | have | find_precedents() — context_graph.py:785 | — |
| Multi-hop reasoning | have | CGR3 loop — context_graph.py:888 | — |
| Temporal reasoning | partial | valid_from/until, is_active() — edge-level only | P2/P3 |
| Structured context delivery | have | RelationContext.to_text() | — |
| Constraint enforcement | gap | no rule eval anywhere | P1 |
| Business-logic encapsulation | gap | no validation/workflow rules | P1 |
| Action / kinetic layer | gap | decisions described, never executed | P1 (slice) · P3 |
| Formal semantic schema / typed properties | gap | open entity-type list — constants.py | P2 |
| Cardinality & edge directionality | gap | graph effectively undirected — routes:517 | P2 |
| Inference / fact derivation | gap | LLM synthesis ≠ entailment | P3 |
| Object-level access control | gap | login + workspace only — auth.py | P3 |
| Agent action-discovery | gap | MCP exposes queries, not actions | P3 |
| Dynamic workflow generation | gap | no planner over typed graph | P3 |
03 P1 Business Rules Engine
Closing constraint-enforcement + the first slice of the action layer, on CG’s grain.
Why this first
It is the single highest-leverage move: it lands three article capabilities at once (constraint enforcement,
business-logic encapsulation, and an executable action slice), it has an obvious anchor point in code
(emit_decision_trace is already the chokepoint for every decision), and it does not require the full
ontology to exist before it pays off. Rules can run against a flat projection of the edge + rc today,
and tighten as the ontology (P2) arrives.
The library
Proposed foundation: manfred-kaiser/business-rule-engine
— a small, dependency-light Python DSL where each rule is a when … then … end block. Conditions are plain
Python boolean expressions over a params dict; then lines call registered functions.
This maps cleanly onto CG: params = the decision being recorded, registered functions = our enforcement
& side-effect verbs.
# business_rule_engine — native shape
from business_rule_engine import RuleParser
def reject(reason): # a registered action verb
raise RuleViolation(reason)
rules = """
rule "approval over $10k needs a manager" priority 10
when
relation_type == "APPROVES"
and amount > 10000
and approver_role != "manager"
then
reject("Approvals over $10,000 require a manager")
end
"""
parser = RuleParser()
parser.register_function(reject)
parser.parsestr(rules)
parser.execute({"relation_type":"APPROVES", "amount":25000, "approver_role":"analyst"})
# → RuleViolation: Approvals over $10,000 require a manager
IF order.value > $10k AND role != manager THEN FORBIDDEN — expressed verbatim as a CG rule.How a CG decision becomes rule params
The bridge is a deterministic projection: flatten the edge, its RelationContext, and (later)
the typed attributes of the head/tail entities into one dict the engine can evaluate. No magic — one function, one
table of derived fields, easy to test.
def project_decision(src, tgt, relation_type, rc, ontology=None) -> dict:
return {
"src": src, "tgt": tgt, "relation_type": relation_type,
"approved_by": rc.approved_by,
"approved_via": rc.approved_via,
"policy_ref": rc.policy_ref,
"confidence": rc.confidence_score,
"valid_from": rc.valid_from, "valid_until": rc.valid_until,
"is_active": rc.is_active(),
# parsed numerics from quantitative_data (e.g. "20% discount", "$25,000")
"amount": parse_amount(rc.quantitative_data),
"percent": parse_percent(rc.quantitative_data),
# P2: typed entity attributes & roles, when the ontology exists
"approver_role": (ontology and ontology.role_of(rc.approved_by)),
}
Where rules fire (enforcement points)
| Hook | When | Rule kind | Effect on a trigger |
|---|---|---|---|
| pre-emit gate | inside emit_decision_trace(), before persist | validation / constraint | reject → 422; or flag → write with needs_review |
| post-ingest sweep | after document extraction writes edges | detective | annotate edge, raise alert, queue for human |
| query-time policy | during /cgr3/query assembly | visibility / advisory | filter or label non-compliant edges in context |
| action verb | then calls a side-effecting function | reactive (action slice) | notify(), open_ticket(), emit_followup_edge() |
A second, CG-native example — discount policy with provenance
rule "large discount must name an approver" priority 20
when
relation_type == "GRANTS_DISCOUNT"
and percent > 0.15
and approved_by is None
then
flag("Discount >15% recorded with no approver — needs sign-off")
end
rule "expired authority" priority 15
when
relation_type == "APPROVES"
and is_active == False
then
flag("Approval references an authority outside its validity window")
end
Both rules read entirely from fields CG already captures — no ontology required to ship them.
04 P1 Fuzzy fact matching — past exact-match brittleness
The objection that breaks a naïve Business Rules Engine on AI data, and the cheap fix: static-vector similarity as a rule predicate.
The flaw
A classic rules engine matches exactly: relation_type == "APPROVES". But in CG almost every field
a rule wants to read is free-text, LLM-extracted, and paraphrased — the same decision surfaces as
APPROVES, GRANTED_APPROVAL, "signed off on", "authorized". Exact
string equality misses all but one spelling, so a hand-written rule silently under-fires. On the data CG actually holds,
exact matching is not a minor limitation — it is disqualifying.
Why model2vec is the right middle tier
The rules gate runs synchronously on every decision write (the pre-emit hook, §03). That latency budget is what rules out the alternatives — an LLM judge or an OpenAI-embedding call per field per event is far too slow and (for the LLM) non-deterministic. model2vec is built for exactly this shape:
Static embeddings (distilled token vectors, no live transformer pass) make per-event encoding sub-millisecond,
so hundreds of rules × a few anchor concepts each cost nothing per write. Candidate models:
potion-retrieval-32M (tuned for matching) or potion-base-8M (smallest);
potion-multilingual-128M if non-English flows appear.
sim() predicate is implemented in the CG-owned package
context_graph/rules/ (not inside the lightrag dependency), with the Concept Catalog,
injectable backend, and business_rule_engine wiring. Empirical check on potion-retrieval-32M
(512-dim) confirms the design: a single anchor is polarity-blind — approved~"signed off on" = 0.117
(synonym) scored below approved~rejected = 0.209 (antonym). The multi-phrase catalog with
max-over-examples fixes it cleanly: matches floor at 0.534, non-matches (incl. antonyms) ceil at 0.238
— the default 0.4 threshold sits in the gap. 13 tests pass (offline + real-model integration).
Integration: sim() as a registered DSL predicate
No fork of the rules engine. business-rule-engine’s when is a Python boolean expression over registered
functions, so we simply register a similarity function backed by model2vec — and it becomes available in every rule.
It returns a float (not a bool with a hidden threshold) so the threshold stays visible in the rule text, which
matters for both the AI author (§05) and human review.
rule "approval control"
when
sim(relation_type, APPROVAL) > 0.62 # SOFT: free-text relation, paraphrase-tolerant
and amount > 10000 # HARD: structured number — never fuzzy
and is_active == True # HARD: structured flag
then
reject("Approvals over $10k need a manager")
end
amount > 10000.The Concept Catalog — don’t embed ad-hoc strings
Rather than each rule embedding its own anchor phrase, define named concepts per workspace, each backed by a
few example phrases and embedded once at compile time (anchors are static; only the incoming fact is embedded
per event). Rules reference concepts by name (APPROVAL above). Matching against the max similarity over
several examples is far more stable than a single anchor.
APPROVAL := ["approved", "signed off on", "authorized", "granted approval"]
REFUND := ["refund issued", "money returned", "reimbursed"]
ESCALATE := ["escalated to", "raised to management", "flagged for review"]
Decisions that make or break it
| Concern | Risk if ignored | Mitigation |
|---|---|---|
| Thresholds | “0.62” is meaningless to an expert and drifts across phrasings | Calibrate from the §05 fixtures — pick the threshold separating positive/negative cases; let the agent propose anchor set + threshold |
| Determinism | Swapping the model silently flips rule outcomes | Pin model id+version with each rule set; embeddings are behaviour |
| Explainability | Fuzzy matches aren’t self-explaining like amount>10000 | Record matched concept + score vs threshold in the eval result: “matched APPROVAL at 0.78 ≥ 0.62” |
| Polarity / negation | Static vectors place “not approved” near “approved” | Use similarity for topical routing only; keep yes/no on structured fields |
| Two vector spaces | Confusion with the 3072-d decisions_vdb retrieval vectors | Name them distinctly; model2vec gating vectors are not comparable to retrieval vectors |
sim(field, CONCEPT) matches one fact field against a concept. Keeps
the boolean DSL intact, composable, fits cleanly. Primary recommendation.(B) Whole-event matching — embed the entire decision and match it against labelled prototype events (“fire when this resembles these past cases”). A different, case-based paradigm — closer to the existing
find_precedents. Optional later: use (B) as a coarse router that selects which (A)-style
rules to evaluate. Not exclusive; start with (A).
potion-retrieval-32M vs potion-base-8M), or do
we eventually distill a CG-domain static model? Is the concept catalog workspace-scoped from day one? Do we
ever escalate near-threshold matches to an LLM judge, or keep the gate purely static for determinism?
05 P1 AI-generated DSL — a rule author per use case
The adoption unlock: turn natural-language policy into validated, executable rules.
Hand-writing DSL is the bottleneck the article’s “expert-authored” principle quietly assumes. We invert it: a domain expert states policy in plain language; an AI agent drafts the DSL, grounded in what fields and verbs actually exist, then we validate before it can ever fire. The agent is constrained, not trusted — every draft passes a parse check and a dry-run on fixtures before a human approves it.
Generation loop
- Ground the agent. Hand it the capability manifest: available
paramsfields (from the projection), registered action verbs (reject,flag,notify…), the ontology vocabulary (P2: object types, properties, link types), and a few canonical rule examples. - Draft. Agent emits one or more
when…then…endblocks from the NL policy, restricted to grounded fields and verbs only. - Validate (hard gate).
RuleParser.parsestr()must parse; every referenced field must exist in the projection; everythenverb must be registered. Parse/lint failure → auto-repair prompt back to the agent (bounded retries). - Dry-run. Execute the candidate against fixture decisions (positive + negative cases the agent also proposes). Show the human exactly which fixtures trigger.
- Explain & approve. Agent renders the rule back into NL (“this blocks…”) for review. Human approves → rule is stored, versioned, and enabled per workspace. Rejection feeds back as guidance.
↑ grounded by
capability manifest (params · verbs · ontology vocab)
DSL draft → parse-check → field/verb lint → dry-run on fixtures → human ✓ → stored rule
Agent drafts — a soft relation match plus hard structured checks:
rule "large discount needs finance review" priority 10
when
sim(relation_type, "APPROVAL") > 0.4 # soft — paraphrase-tolerant
and percent > 0.15 # hard
and approved_via == "slack" # hard
then
flag("Discount >15% approved over Slack — route to Finance for review")
end
Then: parses ✓, concept APPROVAL + fields exist ✓, dry-run flags the
negative fixture (20% via Slack) and passes the positive one (10% via Jira) → expert approves.
→ §06 traces this exact rule firing on a real decision, end to end.
06 P1 Integration — how the Business Rules Engine works with CG
The end-to-end picture: where a decision meets the rules gate, and the concrete plan to wire
the (already-built) sim() predicate into CG.
The whole flow at a glance
sim(); hard predicates stay exact.
The green box is shipped; everything else is the wiring plan below.How a decision finds its edge (and where the two paths converge)
The part worth stating plainly: a CG edge has no separate ID. Its identity is the ordered pair of
entity names — (head, tail) = (src, tgt). “Associating a decision with the existing graph”
just means resolving that pair and looking it up. Both inputs do the same four things to get there.
rc, and is written by the same
upsert_edge — the shared finalization point where the gate belongs.- Resolve the key. Entity names are sanitized / normalized / truncated so “Sarah Chen” always lands on the
same key; the decision’s identity becomes
(src, tgt). - Look up the edge.
get_edge(src, tgt)— found → it already carries anrc; not found → a new edge (and any missing entity nodes) is created. - Merge the
rc. The incoming context is unioned with whatever the edge already holds viaRelationContext.merge()(sentences deduped, first-non-null scalars, max confidence). On ingestion, multiple chunks naming the same pair are merged the same way by_collect_relation_context. - Finalize.
upsert_edge(src, tgt, …)writes the merged edge.
emit_decision_trace() (context_graph.py:700). Ingestion does them in the
merge pipeline (merge_nodes_and_edges → _collect_relation_context,
operate.py:1900), which the emit path never touches. They converge on one call —
upsert_edge(src, tgt). That shared point, not emit_decision_trace alone, is where a
single project_decision() + gate covers both inputs. Edges are effectively undirected: lookups
check (src,tgt) and (tgt,src) (context_graph_routes.py:517), so
the same pair lands on one edge regardless of phrasing order.
Runtime: the pre-emit gate, step by step
Every decision — whether extracted from a document or posted to /graph/decision/emit — passes one
chokepoint before it is written. That chokepoint is where the rules engine lives.
- A decision arrives as
(src, tgt, relation_type, rc)— from CG extraction or the emit endpoint. - Project it to a flat
paramsdict viaproject_decision()(edge +rc+, later, typed ontology attributes from P2). - Evaluate the workspace’s rules. Conditions mix soft predicates
(
sim(relation_type,"APPROVAL") > 0.4, model2vec-backed) and hard ones (amount > 10000,is_active == True). - Gate the outcome: no rule trips → PASS (persist as today);
a
flag()rule trips → FLAG (persist withneeds_review); areject()rule trips → REJECT (HTTP 422, nothing written). - Record the why. On FLAG/REJECT, the matched concept, score, and threshold are written to the audit
record — fuzzy matches stay as explainable as
amount > 10000.
upsert_edge(src,tgt), reached by
emit_decision_trace() for emit and by merge_nodes_and_edges for ingestion (see
“How a decision finds its edge” above) — behind a CG_RULES_ENABLED flag. Off by default → zero
behaviour change; on → both inputs are gated by one project_decision() + evaluate. Earlier we
called this “a single hook in emit_decision_trace”; that covers only the emit path — the accurate
statement is one logical gate at the point both paths converge.
Wiring plan — next steps to connect sim()
The predicate is done; the remaining work is plumbing it into the decision path. Each row is an independent, reviewable PR. built ships today; to build is next.
| # | Component | Module (CG-owned) | Responsibility | Status |
|---|---|---|---|---|
| 1 | Similarity / Concept Catalog | context_graph/rules/similarity.py | The sim() predicate, catalog, model2vec backend. | built ✓ |
| 2 | Decision projection | context_graph/rules/projection.py | project_decision(src,tgt,rel,rc) + project_edge() → params dict; money/percent parsing of quantitative_data. Both write paths proven to project identically. | built ✓ |
| 3 | Engine wrapper | context_graph/rules/engine.py | RulesEngine: compiles a workspace rule set (isolated per-engine functions), binds sim()/similar() + verbs (reject/flag/notify), evaluates rule-by-rule in priority order, fail-open, → EvaluationResult.audit(). | built ✓ |
| 4 | Rule store | context_graph/rules/store.py | RuleBundle (dsl + concepts + version + enabled + pinned model) via JsonRuleStore / InMemoryRuleStore; validates on save (DSL lint + concept-reference check); build_gate(workspace) reconstructs a live gate. | built ✓ |
| 5 | Pre-emit gate | context_graph/rules/gate.py + hook in emit_decision_trace() | RulesGate.check() → GateDecision; REJECT raises RuleViolation (→ 422, nothing written) before any write, FLAG annotates the edge (needs_review + audit), PASS persists. Gate runs on the merged rc. | built ✓ |
| 6 | NL→DSL agent | context_graph/rules/agent.py | RuleAuthor: grounds the LLM in the param/verb/concept manifest, drafts DSL, hard-validates (parse + concept refs), auto-repairs, dry-runs on fixtures. Behind /rules/generate. | built ✓ |
| 7 | Service + REST router | context_graph/rules/service.py · lightrag/api/routers/rules_routes.py | RulesService (store + gate cache + evaluate + attach) and the /rules* endpoints; mounted in the server, gates attached per workspace at startup and on every mutation. | built ✓ |
lightrag/context_graph.py). Everything it calls lives in the CG-owned context_graph/
package — the engine is a dependency the gate calls into, keeping our code separate from the
lightrag library.
Endpoints (alongside /graph/decisions*)
| Method | Path | Purpose |
|---|---|---|
| GET | /rules | Workspace summary: enabled, version, concepts, parsed rule list (name + priority). built ✓ |
| POST | /rules | Set/replace the workspace policy (DSL + concepts); validates & attaches the gate live. built ✓ |
| POST | /rules/evaluate | Dry-run the saved policy against a sample decision → outcome + audit + triggered. built ✓ |
| POST | /rules/toggle | Enable / disable the workspace gate without deleting. built ✓ |
| DEL | /rules | Delete the workspace policy (detaches the gate). built ✓ |
| POST | /rules/generate | NL policy → DSL via the agent; returns draft + dry-run (review-only unless save:true). built ✓ |
decisions_vdb), a
503-guarded router family (like context_graph_routes.py), an opt-in env flag (like
USE_CONTEXT_GRAPH), and a chokepoint to hook (emit_decision_trace). Low architectural risk.
Worked example — one decision, end to end
One concrete decision traced through every step of the diagram above. The numbers are real
(sim() measured on potion-retrieval-32M). The rule below was authored earlier via §05; here
it fires. Outcome is a FLAG — so the decision persists and leaves an
audit trail.
The rule in force (workspace acme)
rule "large discount needs finance review" priority 10
when
sim(relation_type, "APPROVAL") > 0.4 # SOFT — model2vec sim()
and percent > 0.15 # HARD — structured number
and approved_via == "slack" # HARD — controlled vocabulary
then
flag("Discount >15% approved over Slack — route to Finance for review")
end① Decision source — ingestion or emit; both yield the same shape
A deal-desk Slack export is ingested; CG extraction reads one line:
# source chunk (Aug 14)
Sarah Chen: "Approved the 20% discount for MegaCorp — they were about to churn to
Salesforce. Good through Q4."
The equivalent runtime call POST /graph/decision/emit would post the same (src, tgt, relation_type, rc) directly.
② Decision arrives — the quadruple (h, r, t, rc)
{
"src": "Sarah Chen", "tgt": "MegaCorp",
"relation_type": "GRANTED_APPROVAL", # ← paraphrase, NOT the literal "APPROVES"
"rc": {
"decision_trace": "Approved 20% discount; retention risk (Salesforce)",
"quantitative_data": "20% discount",
"approved_by": "Sarah Chen", "approved_via": "slack",
"valid_until": "2026-12-31", "confidence_score": 0.96
}
}
Why exact match fails here: the relation surfaced as GRANTED_APPROVAL, not APPROVES — relation_type == "APPROVES" would silently miss it.
③ project_decision() → flat params
{
"relation_type": "GRANTED_APPROVAL",
"percent": 0.20, # parsed from "20% discount"
"approved_by": "Sarah Chen",
"approved_via": "slack",
"is_active": true, # valid_until 2026-12-31 ≥ today
"confidence": 0.96
}④ Rules engine evaluates the when clause
| Predicate | Kind | Resolves to | Result |
|---|---|---|---|
sim(relation_type, "APPROVAL") > 0.4 | soft | 1.00 > 0.4 | ✓ |
percent > 0.15 | hard | 0.20 > 0.15 | ✓ |
approved_via == "slack" | hard | "slack" == "slack" | ✓ |
All true (AND) → the then action flag(...) fires. sim("GRANTED_APPROVAL","APPROVAL") returns 1.00 — far above the 0.40 cut (non-matches ceil ~0.12).
⑤ Gate → FLAG
A flag() rule tripped (not reject()), so the decision is persisted with needs_review rather than blocked. (Had a reject() rule fired, this would be HTTP 422 and nothing written.)
⑥ Persisted — graph edge + decisions_vdb + audit record
# 1. graph edge (upserted, now carrying the review flag)
Sarah Chen ─[GRANTED_APPROVAL]→ MegaCorp needs_review=true
# 2. decisions_vdb — decision_trace indexed for precedent search (§ find_precedents)
# 3. audit record — the "why", as explainable as a numeric predicate
{
"rule": "large discount needs finance review",
"outcome": "FLAG",
"matched_concept": "APPROVAL", "score": 1.00, "threshold": 0.40,
"reason": "Discount >15% approved over Slack — route to Finance for review",
"model_id": "minishlab/potion-retrieval-32M"
}
APPROVAL · score 1.00 · threshold
0.40 — so a reviewer (or auditor) sees exactly why the rule fired, and pinning
model_id guarantees the same input flags the same way tomorrow. A fuzzy match ends up as accountable as
percent > 0.15.
07 P2 Ontology creation
Give the rules — and the graph — a typed vocabulary to stand on.
Today CG extracts against an open list of entity types (constants.py), relation
“types” are free-text keywords, properties are untyped, and edges are effectively undirected (the edge-context route
has to probe both directions — context_graph_routes.py:517). P2 introduces a real schema so
that rules can reference Order.value or Employee.role with confidence, and so extraction can
be validated rather than trusted.
What the schema layer defines
Object types & typed properties
e.g. Order{ value:money, status:enum }, Employee{ role:enum }. Resurrects the today-unused ContextNode.attributes as typed, persisted state.
Link types & directionality
A catalogue of allowed relations (Customer →places→ Order) with a real head→tail direction, fixing the undirected-edge gap.
Cardinality
1:N / N:M declarations the article calls out — enables validation (“an Order has exactly one Customer”).
Extraction validation
Extracted entities/relations checked against the schema; violations flagged with confidence, not silently kept.
Authoring: top-down + AI-assisted (hybrid)
The article’s principle is “expert-defined before extraction.” We keep the expert in charge but let an agent do the heavy lifting — symmetric with the P1 DSL author:
- Seed from existing signals: the current entity-type list, a sample of extracted edges, and (optionally) an imported DB schema.
- Draft an ontology (types, properties, link types, cardinality) with an agent; expert edits and confirms.
- Bind extraction prompts to the confirmed schema so future ingestion is schema-aware.
rc fields). But P2 makes P1 dramatically stronger: typed
properties and roles become first-class params, so rules graduate from “read what the LLM happened to
extract” to “reason over a validated object model.” Build P1 first; design its projection so P2 fields slot in.
context_graph/ontology/schema.py —
Property (9 typed kinds incl. MONEY/PERCENT, reusing the P1 parsers),
ObjectType, directed LinkType with Cardinality (1:1 · 1:N · N:1 · N:M), and
Ontology with coercing validation ("$25,000" → 25000.0),
self-consistency lint(), and JSON round-trip. Step 2: per-workspace
context_graph/ontology/store.py (JsonOntologyStore / InMemoryOntologyStore,
validate-on-save, version bump). 43 ontology tests; ruff clean.08 P3 Backlog gaps — documented for future discussion
Captured so they are not lost. Each is a later conversation, not a current commitment.
Full action / kinetic layer gap
Beyond P1’s reactive verbs: a registry of executable operations bound to object types
(ApproveOrder(), CancelShipment()) with handlers and side effects.
emit_decision_trace becomes the audit record of an executed action.
Depends on: P2 (object types). Builds on: P1 verbs.
Inference / fact derivation gap
Deterministic entailment (“if A owns B and B owns C…”), distinct from P1’s boolean triggers and from LLM synthesis. Likely a separate rule/reasoner (e.g. SHACL/datalog-style) over the P2 schema. Depends on: P2.
Object-level access control gap
Permissions bound to ontology objects/edges, beyond today’s login + workspace isolation (auth.py). Who may read which decisions / fire which rules / invoke which actions. Depends on: P2 + action layer.
Agent action-discovery gap
Expose the action catalogue over MCP so an agent can discover permitted operations, not just query tools. Depends on: action layer + RBAC.
Dynamic workflow generation gap
A planner that traverses the typed graph to assemble multi-step execution plans (the article’s “30 seconds vs 6 hours” claim). Depends on: action-discovery.
Graph-wide temporal “as-of” partial
Lift per-edge is_active() to a whole-graph as-of query (bitemporal view).
Independent of P1; small, high-value follow-on to P2.
09 Sequencing & dependencies
Proposed order. Each phase ships something usable on its own.
/rules CRUD ▮ ships constraint enforcement│
Phase 2 · P1 agent NL→DSL generation + validation + dry-run +
/rules/generate ▮ ships authoring UX│
Phase 3 · P2 schema object types · typed props · link types · cardinality · extraction validation
│ (feeds richer
params back into P1 rules)▼
Phase 4+ · P3 backlog action layer → inference → object RBAC → action-discovery → workflow planner
10 Open questions to resolve before building
The decisions that change the design. Let’s answer these in the discussion log below.
- Enforcement default: should the pre-emit gate reject (hard 422) or flag (write +
needs_review) by default? Per-rule severity? - Rule scope: rules per-workspace only, or also global/shared templates inherited by workspaces?
- Agent trust boundary: may the rule-author agent propose new action verbs (code), or only compose registered ones?
- Fixtures: expert-authored, sampled from live edges, or agent-synthesised-then-trimmed?
- Numeric parsing: how robust must
parse_amount/parse_percentbe over free-textquantitative_databefore P2 gives us typed numbers? Risk of mis-enforcement. - Ontology standard: RDF/OWL+SHACL vs CG-native schema (lean: native first).
- Model: reuse CG’s LLM binding for both agents, or a dedicated authoring model?
11 Discussion log
Append decisions and notes here as we talk. Newest at the bottom.
sim() DSL predicate, backed by a workspace
Concept Catalog (bridge to P2). Hard predicates stay exact for numbers/dates/flags. See §04.
Recommended path: (A) field-level predicate now, (B) whole-event routing later. Open:
model choice, LLM escalation for near-threshold cases.
sim() predicate first.
CG code now lives in a top-level context_graph/ package — lightrag is a reused
dependency, our work is separate. Shipped context_graph/rules/similarity.py (Concept Catalog, injectable
backend, sim() / similar() predicates), tests in context_graph/tests/,
[rules] extra in pyproject. Validated end-to-end (see §04). Next: wire sim() into the
pre-emit gate + the rest of the engine (projection, store, /rules API).
sim()) shipped, steps 2–7 (projection, engine,
store, gate hook, NL→DSL agent, /rules router) queued. The single hook is
one line in emit_decision_trace() behind CG_RULES_ENABLED.
context_graph/rules/projection.py — project_decision(src,tgt,rel,rc) +
project_edge(edge), with best-effort money/percent parsing of quantitative_data
("20% discount" → percent 0.20, amount None; "$25,000" → 25000). A test proves the
emit tuple and the stored-edge dict project to identical params — the convergence the
§06 "How a decision finds its edge" diagram describes. 39 CG rules tests pass; ruff clean. Next: step 3, the
engine wrapper (engine.py) that registers sim() + action verbs and runs
evaluate(params).
context_graph/rules/engine.py — RulesEngine evaluates rules one at a time in
priority order (clean per-rule audit attribution), is fail-open (a rule that errors on a missing numeric
or unknown concept is skipped with a warning, not fatal), and produces EvaluationResult.audit() with
outcome + matched concept/score/threshold. Workspace isolation handled around a real
business_rule_engine trap: it registers functions on a class-level dict shared by every parser,
so each engine assigns its own functions to each parsed rule. 49 CG rules tests pass; the real-model §06 worked
example reproduces (FLAG, APPROVAL, threshold 0.40).
Footgun found →
business_rule_engine silently parses when <cond> (condition
on the same line as when) as an empty condition — no parse error, blows up at eval. Added a
load-time lint that rejects empty conditions loudly; this is concrete work for the §05 “parse · lint” step
and a rule the NL→DSL agent (step 6) must always satisfy.
context_graph/rules/gate.py (RulesGate · GateDecision ·
RuleViolation) plus a guarded hook in emit_decision_trace(). Order was restructured so the
gate evaluates the merged rc and runs before any write: REJECT raises
RuleViolation (the emit route maps it to HTTP 422, nothing persisted), FLAG persists the
edge annotated with needs_review + the audit JSON, PASS persists unchanged. The emit response now
surfaces outcome + audit. Attach per instance via cg.rules_gate = RulesGate(...)
(config/store wiring is steps 4 & 7). 56 CG tests + 44 existing CG tests pass (no regression from the reorder);
ruff clean. Note: this covers the emit path; gating ingestion means hooking the same logic at
merge_nodes_and_edges’ upsert_edge — a follow-on.
context_graph/rules/store.py — a RuleBundle (DSL + concept catalog + version +
enabled + pinned model_id) persisted by JsonRuleStore (one file/workspace, atomic write)
or InMemoryRuleStore. save() validates before persisting: DSL must parse with non-empty
conditions (reuses step-3 lint) and every concept a rule references must be defined — a broken policy is rejected at
author time. build_gate(workspace) reconstructs a live RulesGate from a bundle (or
None when absent/disabled), closing the loop to step 5. 78 CG tests pass; ruff clean. Next: step 7,
the /rules API, then step 6, the NL→DSL agent.
context_graph/rules/service.py (RulesService: store + per-workspace gate cache
keyed by bundle version + dry-run evaluate + attach) and lightrag/api/routers/rules_routes.py
(GET/POST/DELETE /rules, /rules/evaluate, /rules/toggle;
/rules/generate → 501 until step 6). Mounted in lightrag_server.py when
USE_CONTEXT_GRAPH=true; gates are attached to the live workspace instance on every mutation and for all
stored workspaces at startup (so emit enforces persisted policy after a restart). Per-workspace via the
LIGHTRAG-WORKSPACE header (the WorkspaceProxy forwards rules_gate assignment to
the real instance). 85 CG tests + 80 existing CG tests pass; ruff clean. The gate is now end-to-end: author a
policy over REST → it enforces on the next decision.
Remaining: step 6 — the NL→DSL agent behind
/rules/generate.
context_graph/rules/agent.py (RuleAuthor) and implemented
POST /rules/generate. The agent is grounded in a capability manifest (the param fields from
projection, the action verbs from engine, the workspace's concepts) and the strict DSL
grammar — including the when/then-on-own-lines rule. It drafts → hard-validates
(reusing the store's parse + concept-reference check) → auto-repairs on failure (bounded) → dry-runs
the draft against fixtures, returning the outcome per fixture. Review-only by default; save:true
persists + attaches. Provider-agnostic (uses the workspace's llm_model_func). 91 CG tests pass; ruff
clean. All seven P1 wiring steps are now built — sim · projection · engine · store · gate · API · agent.
context_graph/ontology/ — Property
(typed kinds, enum, numeric bounds, coercion), ObjectType, directed LinkType +
Cardinality, and Ontology (validate entity/relation, lint(), JSON round-trip).
Money/percent validation reuses the P1 projection parsers so both layers read free-text numbers the same
way. 30 ontology tests (121 CG tests total); ruff clean. Next P2 steps: ontology store (per-workspace, like the
rules store) → extraction validation (check LLM output against the schema) → AI-assisted authoring → API → wire typed
properties into projection so rules gain typed params.
context_graph/ontology/store.py — per-workspace persistence mirroring the rules store
(JsonOntologyStore / InMemoryOntologyStore, atomic write). Validate-on-save runs the
ontology's lint() (rejects links referencing undefined object types before persisting) and bumps
version without mutating the caller's object. 43 ontology tests (134 CG total); ruff clean. Next: extraction
validation — check LLM-extracted entities/relations against the stored ontology and flag violations.