KG edge schema reference¶
Module: zettelforge.knowledge_graph
from zettelforge.knowledge_graph import KnowledgeGraph, get_knowledge_graph
Overview¶
ZettelForge maintains a knowledge graph of entities and typed relationships extracted from analyst notes and detection rules. The graph stores entity nodes and directed relationship edges with optional temporal indexing for time-based queries.
Two storage paths exist in parallel:
- SQLite (default). When you use
MemoryManagerwith the default backend, KG writes go to thekg_nodesandkg_edgestables inzettelforge.db. This is the primary path for most deployments. - JSONL in-process. The
KnowledgeGraphclass (below) writes tokg_nodes.jsonlandkg_edges.jsonlin the data directory. It is used when you callget_knowledge_graph()directly or when the TypeDB extension is not installed and the backend is explicitly set to something other than SQLite.
The edge field names and relationship types are identical in both paths. The SQLite edge table adds a note_id column (see below).
On ThreatRecall.ai SaaS, the graph backend is TypeDB, which provides a richer semantic layer. All relationship types documented here apply across backends.
Thread safety: All write operations in KnowledgeGraph use threading.RLock.
Node schema¶
Nodes are created automatically when edges reference them. The entity_type field identifies the domain; the entity_value holds the canonical string.
Node fields¶
| Field | Type | Description |
|---|---|---|
node_id |
str |
node_<12-hex> — internal primary key |
entity_type |
str |
Entity type string (see taxonomy below) |
entity_value |
str |
Canonical value, e.g. apt28, CVE-2024-3094 |
properties |
dict |
Application-specific metadata |
created_at |
str |
ISO 8601 |
updated_at |
str |
ISO 8601 |
Entity type taxonomy¶
Entity types written by each subsystem. Strings are case-sensitive and written exactly as shown.
CTI domain (written by MemoryManager._update_knowledge_graph()):
entity_type |
Description |
|---|---|
note |
MemoryNote stored via remember() |
actor |
Threat actor via regex extraction |
threat_actor |
Threat actor via LLM NER |
intrusion_set |
Intrusion set (e.g. APT group) |
tool |
Malware or tool name |
cve |
Vulnerability identifier |
asset |
Target asset or sector |
campaign |
Campaign name |
attack_pattern |
MITRE ATT&CK technique (e.g. T1059) |
malware |
Malware family name |
person |
Person name (conversational domain) |
location |
Location (conversational domain) |
organization |
Organization name |
event |
Named event |
activity |
Activity name |
temporal |
Temporal reference extracted from text |
Sigma and YARA domain (written by sigma/YARA ingest):
entity_type |
Description |
|---|---|
SigmaRule |
Sigma detection rule |
YaraRule |
YARA detection rule |
SigmaTag |
Raw Sigma tag string |
YaraTag |
Raw YARA tag string |
LogSource |
Sigma logsource facet |
AttackPattern |
MITRE ATT&CK technique resolved from tag |
Vulnerability |
CVE resolved from tag |
IntrusionSet |
MITRE ATT&CK group resolved from sigma tag |
Malware |
MITRE ATT&CK software resolved from sigma tag |
ThreatActor |
Named threat actor resolved from YARA metadata |
SQLite DDL¶
CREATE TABLE IF NOT EXISTS kg_nodes (
node_id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_value TEXT NOT NULL,
properties TEXT DEFAULT '{}',
created_at TEXT,
updated_at TEXT,
UNIQUE(entity_type, entity_value)
);
Edge schema¶
Each edge is a directed relationship between two nodes.
Edge fields¶
| Field | Type | Description |
|---|---|---|
edge_id |
str |
edge_<12-hex> — internal primary key |
from_node_id |
str |
Source node node_id |
to_node_id |
str |
Target node node_id |
relationship |
str |
Semantic relationship type (see below) |
edge_type |
str |
heuristic | causal | detection (default: heuristic) |
properties |
dict |
Edge-specific metadata |
created_at |
str |
ISO 8601 |
updated_at |
str |
ISO 8601 |
SQLite DDL¶
The SQLite path adds note_id, which links an edge back to the note that caused it. The unique constraint includes note_id, so the same entity pair can have the same relationship recorded from multiple notes.
CREATE TABLE IF NOT EXISTS kg_edges (
edge_id TEXT PRIMARY KEY,
from_node_id TEXT NOT NULL,
to_node_id TEXT NOT NULL,
relationship TEXT NOT NULL,
edge_type TEXT DEFAULT 'heuristic',
note_id TEXT DEFAULT '',
properties TEXT DEFAULT '{}',
created_at TEXT,
updated_at TEXT,
UNIQUE(from_node_id, to_node_id, relationship, note_id)
);
The JSONL path deduplicates on (from_node_id, to_node_id, relationship) without note_id.
Edge type taxonomy¶
The edge_type field records how the edge was created:
edge_type |
Description | Source |
|---|---|---|
heuristic |
Co-occurrence or heuristic extraction (default) | Entity co-occurrence during remember() |
causal |
LLM-extracted cause-and-effect triple | Causal triple extraction in slow-path enrichment |
detection |
Detection rule relationship | Sigma/YARA ingest |
When add_edge() is called on an existing heuristic edge with a more specific edge_type, the type is promoted to the more specific value.
Relationship types¶
CTI entity relationships¶
Created automatically during remember() with domain="cti".
| Relationship | From | To | Description |
|---|---|---|---|
MENTIONED_IN |
any entity | note |
Entity appeared in a note |
USES_TOOL |
actor, threat_actor, intrusion_set |
tool |
Actor uses a specific tool |
EXPLOITS_CVE |
actor, threat_actor, intrusion_set, tool |
cve |
Entity exploits a vulnerability |
TARGETS_ASSET |
actor, threat_actor, intrusion_set, tool |
asset |
Entity targets an asset or sector |
CONDUCTS_CAMPAIGN |
actor, threat_actor, intrusion_set |
campaign |
Actor runs a campaign |
USES_TECHNIQUE |
actor, threat_actor, intrusion_set |
attack_pattern |
Actor uses an ATT&CK technique |
IMPLEMENTS |
malware |
attack_pattern |
Malware implements a technique |
Conversational entity relationships (extracted when domain is not restricted to CTI):
| Relationship | From | To | Description |
|---|---|---|---|
AFFILIATED_WITH |
person |
organization |
Person affiliated with an org |
ATTENDED |
person |
event |
Person attended an event |
LOCATED_AT |
person |
location |
Person located at a place |
PARTICIPATES_IN |
person |
activity |
Person participates in an activity |
HELD_AT |
event |
location |
Event held at a location |
ORGANIZED_BY |
event |
organization |
Event organized by an org |
OCCURRED_ON |
event |
temporal |
Event at a temporal reference |
BASED_IN |
organization |
location |
Organization based in a location |
Note versioning:
| Relationship | From | To | Description |
|---|---|---|---|
SUPERSEDES |
note |
note |
Newer note version supersedes the older one |
LLM causal relationships¶
Created by NoteConstructor.store_causal_edges() during slow-path enrichment. These edges have edge_type="causal". Extraction requires a configured LLM provider.
| Relationship | Description |
|---|---|
causes |
Subject is a direct cause of object |
enables |
Subject enables or facilitates object |
targets |
Subject targets object |
uses |
Subject uses object |
exploits |
Subject exploits object |
attributed_to |
Subject is attributed to object |
related_to |
Generic causal relationship |
The relationship value is the lowercase string as validated against CAUSAL_RELATIONS (note_constructor.py:98). Node entity_type and entity_value for causal edges are free-form strings extracted by the LLM and are not constrained to the entity type taxonomy above.
Detection rule relationships¶
Created during Sigma and YARA ingest.
| Relationship | From | To | Description |
|---|---|---|---|
applies_to |
SigmaRule |
LogSource |
Rule applies to a log source facet |
tagged_with |
SigmaRule, YaraRule |
SigmaTag, YaraTag |
Rule carries a tag |
detects |
SigmaRule, YaraRule |
AttackPattern |
Rule detects an ATT&CK technique |
references_cve |
SigmaRule, YaraRule |
Vulnerability |
Rule references a CVE |
attributed_to |
SigmaRule, YaraRule |
IntrusionSet, Malware, ThreatActor |
Rule attributed to a group or actor |
superseded_by |
SigmaRule |
SigmaRule |
Rule replaced by a newer version |
related_to |
SigmaRule |
SigmaRule |
Generic rule relationship |
Temporal relationships¶
For tracking entity state over time. Used with add_temporal_edge().
| Relationship | Description |
|---|---|
TEMPORAL_BEFORE |
State or event occurred before another |
TEMPORAL_AFTER |
State or event occurred after another |
SUPERSEDES |
New entity state supersedes an old one |
Temporal edges are indexed automatically in _temporal_index and _entity_timeline on write.
Legacy schema normalization¶
Edges written by pre-v2.5.1 deployments used different key names. _normalize_edge_schema() rewrites them on load:
| Legacy key | Canonical key |
|---|---|
source_id |
from_node_id |
target_id |
to_node_id |
relation_type |
relationship |
Rules for rejected entries:
- Entry missing
edge_id: dropped. - Entry missing
from_node_id,to_node_id, orrelationship(and no recoverable legacy keys): dropped. - Malformed JSON line: skipped; total count logged at
warninglevel askg_edges_skipped_malformed.
This was a production hotfix (v2.5.1) for long-running deployments where pre-v2.5.x writers had written ~80k+ legacy entries alongside canonical-shape rows in kg_edges.jsonl.
KnowledgeGraph class API¶
Use KnowledgeGraph directly when you need in-process JSONL graph access, or call get_knowledge_graph() for the shared singleton.
add_node()¶
def add_node(self, entity_type: str, entity_value: str, properties: dict | None = None) -> str
Creates or updates a node. Returns node_id. If the node already exists, merges properties and refreshes updated_at.
add_edge()¶
def add_edge(
self,
from_type: str,
from_value: str,
to_type: str,
to_value: str,
relationship: str,
properties: dict | None = None,
) -> str
Creates or updates a directed edge. Auto-creates both endpoint nodes. Deduplicates on (from_node_id, to_node_id, relationship). If a duplicate exists with edge_type="heuristic" and you pass a more specific edge_type in properties, it is promoted.
get_node()¶
def get_node(self, entity_type: str, entity_value: str) -> dict | None
Looks up a node by type and value. Returns None if not found.
get_node_by_id()¶
def get_node_by_id(self, node_id: str) -> dict | None
Looks up a node by its internal node_id.
get_outgoing_edges()¶
def get_outgoing_edges(self, node_id: str) -> list[dict]
Returns all outgoing edges for a node_id. Each dict contains at minimum edge_id, from_node_id, to_node_id, relationship, properties, created_at, updated_at.
get_neighbors()¶
def get_neighbors(
self, entity_type: str, entity_value: str, relationship: str | None = None
) -> list[dict]
Returns adjacent nodes reachable via outgoing edges, with optional relationship filter. Each result contains node (the target node dict), relationship, and edge_properties.
traverse()¶
def traverse(self, start_type: str, start_value: str, max_depth: int = 2) -> list[dict]
Depth-first traversal up to max_depth. Returns a list of paths; each path is a list of step dicts with keys from_type, from_value, relationship, to_type, to_value.
Temporal indexing¶
add_temporal_edge()¶
def add_temporal_edge(
self,
from_type: str,
from_value: str,
to_type: str,
to_value: str,
relationship: str, # TEMPORAL_BEFORE | TEMPORAL_AFTER | SUPERSEDES
timestamp: str,
properties: dict | None = None,
) -> str
Creates an edge and indexes it in _temporal_index (keyed by timestamp string) and _entity_timeline (keyed by "entity_type:entity_value").
Timestamp formats accepted by _parse_timestamp():
- ISO 8601 (including
Zsuffix) %Y-%m-%d%Y-%m-%d %H:%M:%S%d %b %Y(e.g.15 Jan 2026)%B %d, %Y(e.g.January 15, 2026)
get_entity_timeline()¶
def get_entity_timeline(self, entity_type: str, entity_value: str) -> list[dict]
Returns the chronological state history for an entity. Each entry: {"edge": dict, "timestamp": str, "to_entity": "type:value"}.
get_changes_since()¶
def get_changes_since(self, timestamp: str) -> list[dict]
Returns all temporal entity changes at or after timestamp. Results are sorted chronologically. Each entry: {"timestamp": str, "from": "type:value", "relationship": str, "to": "type:value"}.
get_latest_state()¶
def get_latest_state(self, entity_type: str, entity_value: str) -> dict | None
Returns the most recent temporal edge entry for an entity, or None if no temporal data exists.
Causal edge queries¶
Causal edges (edge_type: "causal") represent LLM-extracted cause-and-effect triples. Two dedicated methods traverse them.
get_causal_edges()¶
def get_causal_edges(
self, entity_type: str, entity_value: str,
max_depth: int = 3, max_visited: int = 50,
) -> list[dict]
BFS over outgoing causal edges — traces forward from cause to effects. Useful for "what does this actor or event lead to?" queries.
get_incoming_causal()¶
def get_incoming_causal(
self, entity_type: str, entity_value: str,
max_depth: int = 3, max_visited: int = 50,
) -> list[dict]
BFS over incoming causal edges — traces back to root causes. Useful for "why did this happen?" queries.
Global singleton¶
def get_knowledge_graph() -> KnowledgeGraph
Returns the process-global KnowledgeGraph instance. Checks ZETTELFORGE_BACKEND (default: sqlite):
- If
ZETTELFORGE_BACKEND=typedband the TypeDB extension is installed, uses TypeDB. On ThreatRecall.ai SaaS, TypeDB is the active KG backend. - Otherwise: uses the JSONL
KnowledgeGraphinstance.
Note: when you use MemoryManager with the default SQLite backend, KG writes go directly to the SQLite tables via self.store.add_kg_edge(), not through get_knowledge_graph(). Call get_knowledge_graph() for direct graph access independent of the memory manager.
Examples¶
from zettelforge.knowledge_graph import KnowledgeGraph
kg = KnowledgeGraph()
# Add nodes and an edge
kg.add_node("actor", "apt28")
kg.add_node("tool", "cobalt strike")
edge_id = kg.add_edge("actor", "apt28", "tool", "cobalt strike", "USES_TOOL")
# Query neighbors
neighbors = kg.get_neighbors("actor", "apt28")
for n in neighbors:
print(f"{n['relationship']} -> {n['node']['entity_type']}:{n['node']['entity_value']}")
# Output: USES_TOOL -> tool:cobalt strike
# Traverse up to depth 2
paths = kg.traverse("actor", "apt28", max_depth=2)
for path in paths:
print(" -> ".join(
f"{p['from_type']}:{p['from_value']} [{p['relationship']}] {p['to_type']}:{p['to_value']}"
for p in path
))
from zettelforge.knowledge_graph import KnowledgeGraph
kg = KnowledgeGraph()
# Temporal edge: record that an event preceded another
kg.add_temporal_edge(
from_type="actor", from_value="apt28",
to_type="campaign", to_value="nato-phishing-2026-q1",
relationship="TEMPORAL_BEFORE",
timestamp="2026-01-15",
)
# What changed since the start of Q1?
changes = kg.get_changes_since("2026-01-01")
for c in changes:
print(f"[{c['timestamp']}] {c['from']} {c['relationship']} {c['to']}")
# Causal chain: forward (cause → effects)
causes = kg.get_causal_edges("actor", "apt28", max_depth=3)
# Causal chain: backward (root cause analysis)
root_causes = kg.get_incoming_causal("campaign", "nato-phishing-2026-q1", max_depth=3)