Working document · Discussion & Proposal

Closing the Gaps

A starting point for deciding how Context Graph evolves from a contextual retrieval memory toward an operational ontology. Driven by the gap analysis against “Ontology just beat vector embeddings.” Prioritised: (1) a Business Rules Engine (BRE) with AI-generated DSL, (2) Ontology creation, (3) everything else captured for later.

P1 Business Rules Engine P2 Ontology creation P3 Backlog gaps AI-authored DSL status: draft for discussion

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.

P1
Business Rules Engine + AI DSL
P2
Ontology: typed schema & cardinality
5
backlog gaps documented (P3)
0→1
from system-of-record to system-of-policy
The one idea
CG today records that a decision happened (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 capabilityCG todayEvidence / noteAddressed by
Decision-history trackingexceedsrc 11-field record, decisions_vdbcontext_graph_types.py:16
Precedent discoveryhavefind_precedents()context_graph.py:785
Multi-hop reasoninghaveCGR3 loop — context_graph.py:888
Temporal reasoningpartialvalid_from/until, is_active() — edge-level onlyP2/P3
Structured context deliveryhaveRelationContext.to_text()
Constraint enforcementgapno rule eval anywhereP1
Business-logic encapsulationgapno validation/workflow rulesP1
Action / kinetic layergapdecisions described, never executedP1 (slice) · P3
Formal semantic schema / typed propertiesgapopen entity-type list — constants.pyP2
Cardinality & edge directionalitygapgraph effectively undirected — routes:517P2
Inference / fact derivationgapLLM synthesis ≠ entailmentP3
Object-level access controlgaplogin + workspace only — auth.pyP3
Agent action-discoverygapMCP exposes queries, not actionsP3
Dynamic workflow generationgapno planner over typed graphP3

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
The article’s worked exampleIF 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)),
    }
The projection is the contract between CG’s data model and the rule language. Adding a derived field here is how we widen what rules can express.

Where rules fire (enforcement points)

HookWhenRule kindEffect on a trigger
pre-emit gateinside emit_decision_trace(), before persistvalidation / constraintreject → 422; or flag → write with needs_review
post-ingest sweepafter document extraction writes edgesdetectiveannotate edge, raise alert, queue for human
query-time policyduring /cgr3/query assemblyvisibility / advisoryfilter or label non-compliant edges in context
action verbthen calls a side-effecting functionreactive (action slice)notify(), open_ticket(), emit_followup_edge()
Scope honesty
business-rule-engine evaluates Python boolean expressions and triggers actions. That fully covers constraint enforcement and a reactive action slice — it is not an OWL-style entailment/inference engine. True fact-derivation stays in P3 (§08). We should not over-promise “inference” from this component.
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.

The matching spectrum
Think of three tiers, cheapest first — the engine should use the cheapest that works and escalate only when needed: exact (free, self-explaining, right for numbers/dates/flags) → static-vector similarity (cheap, deterministic, right for free-text categoricals & prose) → LLM judgment (expensive, non-deterministic — escalation only, e.g. near-threshold cases). Today’s gap is the missing middle tier.

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:

~500×
faster than the source sentence-transformer
numpy
only major dep at inference — no torch, runs on CPU
8–30 MB
on disk (potion-base-8M … 32M)
MIT
license — matches CG; static = deterministic

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.

Built & validated (2026-06-30)
The field-level 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
The natural split falls out for free: hard predicates for structured facts (numbers, dates, flags), soft predicates for semantic facts (relation type, decision_trace, channel). You never want fuzzy on 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"]
The catalog centralises curation and calibration — and is the natural bridge to P2: ontology link-types / event-types are these concepts. P1 ships a flat catalog; P2 takes ownership of it.

Decisions that make or break it

ConcernRisk if ignoredMitigation
Thresholds“0.62” is meaningless to an expert and drifts across phrasingsCalibrate from the §05 fixtures — pick the threshold separating positive/negative cases; let the agent propose anchor set + threshold
DeterminismSwapping the model silently flips rule outcomesPin model id+version with each rule set; embeddings are behaviour
ExplainabilityFuzzy matches aren’t self-explaining like amount>10000Record matched concept + score vs threshold in the eval result: “matched APPROVAL at 0.78 ≥ 0.62”
Polarity / negationStatic vectors place “not approved” near “approved”Use similarity for topical routing only; keep yes/no on structured fields
Two vector spacesConfusion with the 3072-d decisions_vdb retrieval vectorsName them distinctly; model2vec gating vectors are not comparable to retrieval vectors
Recommended path — (A) field-level, with (B) as an optional router
(A) Field-level predicatesim(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).
For discussion
Which pretrained model do we standardise on (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

  1. Ground the agent. Hand it the capability manifest: available params fields (from the projection), registered action verbs (reject, flag, notify…), the ontology vocabulary (P2: object types, properties, link types), and a few canonical rule examples.
  2. Draft. Agent emits one or more when…then…end blocks from the NL policy, restricted to grounded fields and verbs only.
  3. Validate (hard gate). RuleParser.parsestr() must parse; every referenced field must exist in the projection; every then verb must be registered. Parse/lint failure → auto-repair prompt back to the agent (bounded retries).
  4. Dry-run. Execute the candidate against fixture decisions (positive + negative cases the agent also proposes). Show the human exactly which fixtures trigger.
  5. 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.
NL policy → [ Rule-Author Agent ] → DSL draft
                     ↑ grounded by
          capability manifest (params · verbs · ontology vocab)

DSL draft → parse-check → field/verb lint → dry-run on fixtures → human ✓ → stored rule
The agent never writes directly to the live rule set; parse + lint + dry-run + human approval sit between draft and enforcement.
Example round-trip
Expert says: “Flag any discount over 15% that was approved over Slack, so Finance can review it.”
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.
For discussion
Reuse the existing CG LLM binding for the agent, or a dedicated stronger model? Where do fixtures come from — expert-authored, sampled from real edges, or agent-synthesised then human-trimmed? Do we let the agent propose new action verbs (needs code) or only compose registered ones (safer)?

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

AUTHORING · §05 NL policy Rule-Author Agent NL → DSL draft parse · lint · dry-run + human approve Rule Store per workspace · versioned DECISION SOURCE Document ingestion → CG extraction (edge + rc) POST /graph/decision/emit runtime decision project_decision() edge + rc (+ ontology) → params { } RULES ENGINE · context_graph/rules business_rule_engine evaluate(params) soft sim(field, CONCEPT) > t hard amount / date / flag == Concept Catalog → sim() : float model2vec · potion-retrieval-32M (512-d) ✓ built & validated sim() GATE PASS persist FLAG + needs_review REJECT 422 · blocked Graph edge + decisions_vdb + audit record matched concept · score · threshold ✕ not persisted
Runtime path (teal → blue → pink → outcome) meets the authoring path (purple) at the Rule Store. Soft predicates use model2vec 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.

Document chunk(s) → CG extraction POST /graph/decision/emit runtime decision (src, tgt, rel, rc) ① Resolve key (src, tgt) normalize entity names ② Existing graph h t head / tail entity get_edge(src, tgt) ③ Merge rc RelationContext .merge(existing, new…) ④ Finalize upsert_edge(src, tgt) ★ gate sits here Persisted edge + decisions_vdb + audit Join key = head + tail entity names — no separate edge ID Same 4 steps, two code paths: emit → emit_decision_trace() ingestion → merge_nodes_and_edges() both end at upsert_edge()
One (src, tgt) key, one edge. Whether a decision arrives as prose (ingestion) or JSON (emit), it resolves the same key, looks up the same edge, merges into the same rc, and is written by the same upsert_edgethe shared finalization point where the gate belongs.
  1. 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).
  2. Look up the edge. get_edge(src, tgt) — found → it already carries an rc; not found → a new edge (and any missing entity nodes) is created.
  3. Merge the rc. The incoming context is unioned with whatever the edge already holds via RelationContext.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.
  4. Finalize. upsert_edge(src, tgt, …) writes the merged edge.
Two code paths, one finalization point
Both inputs run the same four steps — in different code. Emit does them inline in 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.

  1. A decision arrives as (src, tgt, relation_type, rc) — from CG extraction or the emit endpoint.
  2. Project it to a flat params dict via project_decision() (edge + rc +, later, typed ontology attributes from P2).
  3. 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).
  4. Gate the outcome: no rule trips → PASS (persist as today); a flag() rule trips → FLAG (persist with needs_review); a reject() rule trips → REJECT (HTTP 422, nothing written).
  5. 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.
Where the hook goes
The gate sits at the shared edge-finalization pointupsert_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.

#ComponentModule (CG-owned)ResponsibilityStatus
1Similarity / Concept Catalogcontext_graph/rules/similarity.pyThe sim() predicate, catalog, model2vec backend.built ✓
2Decision projectioncontext_graph/rules/projection.pyproject_decision(src,tgt,rel,rc) + project_edge()params dict; money/percent parsing of quantitative_data. Both write paths proven to project identically.built ✓
3Engine wrappercontext_graph/rules/engine.pyRulesEngine: 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 ✓
4Rule storecontext_graph/rules/store.pyRuleBundle (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 ✓
5Pre-emit gatecontext_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 ✓
6NL→DSL agentcontext_graph/rules/agent.pyRuleAuthor: 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 ✓
7Service + REST routercontext_graph/rules/service.py · lightrag/api/routers/rules_routes.pyRulesService (store + gate cache + evaluate + attach) and the /rules* endpoints; mounted in the server, gates attached per workspace at startup and on every mutation.built ✓
Boundary note
The hook (5) is the one line that touches the existing decision path (currently in 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*)

MethodPathPurpose
GET/rulesWorkspace summary: enabled, version, concepts, parsed rule list (name + priority). built ✓
POST/rulesSet/replace the workspace policy (DSL + concepts); validates & attaches the gate live. built ✓
POST/rules/evaluateDry-run the saved policy against a sample decision → outcome + audit + triggered. built ✓
POST/rules/toggleEnable / disable the workspace gate without deleting. built ✓
DEL/rulesDelete the workspace policy (detaches the gate). built ✓
POST/rules/generateNL policy → DSL via the agent; returns draft + dry-run (review-only unless save:true). built ✓
Why it fits
Every piece parallels something CG already has: per-workspace storage (like 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 APPROVESrelation_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

PredicateKindResolves toResult
sim(relation_type, "APPROVAL") > 0.4soft1.00 > 0.4
percent > 0.15hard0.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"
}
The payoff
The flag is fully traceable — matched concept 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.
P1 ↔ P2 dependency
P1 ships without P2 (rules over flat 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.
Decided ✓ · CG-native schema
Resolved: build a CG-native schema (dataclasses/JSON), standards-export later — not RDF/OWL up front.
Built (step 1 of P2): the schema model ships in context_graph/ontology/schema.pyProperty (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.

Phase 1 · P1 core engine + projection + pre-emit gate + /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
P1 is independent and first. P2 deepens P1. P3 items mostly depend on P2 and on each other — sequenced, not parallel.

10 Open questions to resolve before building

The decisions that change the design. Let’s answer these in the discussion log below.

  1. Enforcement default: should the pre-emit gate reject (hard 422) or flag (write + needs_review) by default? Per-rule severity?
  2. Rule scope: rules per-workspace only, or also global/shared templates inherited by workspaces?
  3. Agent trust boundary: may the rule-author agent propose new action verbs (code), or only compose registered ones?
  4. Fixtures: expert-authored, sampled from live edges, or agent-synthesised-then-trimmed?
  5. Numeric parsing: how robust must parse_amount/parse_percent be over free-text quantitative_data before P2 gives us typed numbers? Risk of mis-enforcement.
  6. Ontology standard: RDF/OWL+SHACL vs CG-native schema (lean: native first).
  7. 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.

2026-06-30 · seed
Document created from the blog gap analysis. Priorities set: P1 Business Rules Engine (on business-rule-engine) with AI-generated DSL per use case; P2 Ontology creation; P3 remaining gaps documented above.
2026-06-30 · fuzzy matching
Identified exact-match as the disqualifying flaw for a Business Rules Engine over CG’s free-text, LLM-extracted fields. Decision: add a static-vector similarity tier via model2vec (numpy-only, MIT, CPU sub-ms) exposed as a 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.
2026-06-30 · decisions + first build
Locked: model = potion-retrieval-32M; build the (A) field-level 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).
2026-06-30 · integration architecture
Documented the full Business Rules Engine × CG integration in §06: a colour-coded diagram (runtime decision path meets the authoring path at the Rule Store), the pre-emit gate walk-through (PASS / FLAG / REJECT), and the 7-step wiring plan with build status — step 1 (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.
2026-06-30 · wiring step 2 built
Shipped context_graph/rules/projection.pyproject_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).
2026-06-30 · wiring step 3 built
Shipped context_graph/rules/engine.pyRulesEngine 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.
2026-06-30 · wiring step 5 built — the gate is live
Shipped 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_edgesupsert_edge — a follow-on.
2026-06-30 · wiring step 4 built
Shipped 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.
2026-06-30 · wiring step 7 built — rules are live over REST
Shipped 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.
2026-06-30 · wiring step 6 built — P1 is complete
Shipped 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.
2026-07-01 · P2 started — ontology schema model
Resumed the gaps project (P1 complete). Decisions locked: CG-native schema (dataclasses/JSON, standards-export later), and build the schema model first. Shipped 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.
2026-07-01 · P2 step 2 — ontology store
Shipped 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.
— · your turn
Add the next note here: pick a stance on any open question in §10, or scope Phase 1.
↑ Top