orchestrata_ultimate.orchestrator.graph_audit
Reference › orchestrata_ultimate › orchestrator › graph_audit
The durable audit trail for governance. GraphAuditTrail receives events from the engine and the governance package and writes them to the knowledge graph without ever blocking or breaking the caller. Its importers are engine, intent_history and the governance modules governance.audit, governance.violations and the escalation handler. It depends on core.graph_factory, core.redis_factory, the knowledge_graph package and its canonical schema, and traces to spec 003 story US-009.
The write path is a queue. Each log_* method (log_event, log_policy_decision, log_session_start, log_session_end, log_tool_call, log_violation, log_escalation, log_resolution, log_timeout) calls _enqueue, which pushes a JSON event onto a Redis list; with no Redis client the event is counted as dropped (get_dropped_events_count). start creates the Redis client when none was injected (from REDIS_HOST, REDIS_PORT and REDIS_DB through the factory) and launches _consumer_loop, a background task that pops events and calls _write_event. That method maps the event type to a graph label, takes the session id (or the escalation id) as anchor, stamps the label's canonical merge key from the canonical schema, writes the node and links it to its Session node, creating the session lazily. Unknown event types and an unavailable graph return false with a warning. stop, close and aclose shut the consumer down.
The data types are AuditEventType (violation, escalation, resolution, timeout, event, policy decision, session start and end, tool call, volk lifecycle, error, compliance check, intent recorded), DecisionOutcome, GraphAuditConfig, RedisConfig, AuditEvent and PolicyDecision. Read methods are get_escalations, get_violations, get_resolutions, query_audit_trail and health_check.
At a glance
| Source | src/orchestrata_ultimate/orchestrator/graph_audit.py |
| Lines of code | 674 |
| Classes | 7 |
| Functions | 2 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.graph, orchestrata_ultimate.core.graph_factory, orchestrata_ultimate.core.redis_factory, orchestrata_ultimate.governance.escalation_handler, orchestrata_ultimate.knowledge_graph, orchestrata_ultimate.knowledge_graph.canonical_schema |
| Imported by | orchestrata_ultimate.governance.audit, orchestrata_ultimate.governance.escalation_handler.handler, orchestrata_ultimate.governance.escalation_handler.resolver, orchestrata_ultimate.governance.escalation_handler.sla, orchestrata_ultimate.governance.violations, orchestrata_ultimate.orchestrator.engine, orchestrata_ultimate.orchestrator.intent_history |
| Tests that import it | tests/generated_orchestrator/test_graph_audit_generated.py, tests/governance/test_audit.py, tests/governance/test_audit_coverage.py, tests/governance/test_error_paths.py, tests/governance/test_escalation_handler.py, tests/governance/test_violations.py, tests/governance/test_violations_coverage.py, tests/integration/test_graph_audit_integration.py, tests/orchestrator/test_engine_behavioral.py, tests/orchestrator/test_graph_audit_behavioral.py, tests/orchestrator/test_intent_lifecycle_behavioral.py, tests/orchestrator/test_orchestrator_defect_regressions.py |
Class diagram
Dependency diagram
API
Classes
AuditEventType (enum)
Bases: Enum · Decorators: traces(spec='003', story='US-009', reqs=['REQ-003-US-009-001']) · Line 69
Types of audit events.
| Member | Value |
|---|---|
VIOLATION |
'violation' |
ESCALATION |
'escalation' |
RESOLUTION |
'resolution' |
TIMEOUT |
'timeout' |
EVENT |
'event' |
POLICY_DECISION |
'policy_decision' |
SESSION_START |
'session_start' |
SESSION_END |
'session_end' |
TOOL_CALL |
'tool_call' |
VOLK_LIFECYCLE |
'volk_lifecycle' |
ERROR |
'error' |
COMPLIANCE_CHECK |
'compliance_check' |
INTENT_RECORDED |
'intent_recorded' |
DecisionOutcome (enum)
Bases: Enum · Decorators: traces(spec='003', story='US-009', reqs=['REQ-003-US-009-001']) · Line 88
Governance decision outcomes.
| Member | Value |
|---|---|
ALLOWED |
'allowed' |
DENIED |
'denied' |
ESCALATED |
'escalated' |
PENDING |
'pending' |
GraphAuditConfig (dataclass)
Decorators: dataclass, traces(spec='003', story='US-009', reqs=['REQ-003-US-009-001']) · Line 99
Graph connection configuration.
| Attribute | Type | Default |
|---|---|---|
uri |
str |
field(default_factory=resolve_uri) |
username |
str |
'' |
password |
str |
'' |
database |
str |
field(default_factory=resolve_kg_database) |
max_connection_lifetime |
int |
3600 |
max_connection_pool_size |
int |
50 |
RedisConfig (dataclass)
Decorators: dataclass, traces(spec='003', story='US-009', reqs=['REQ-003-US-009-001']) · Line 118
Redis connection configuration.
| Attribute | Type | Default |
|---|---|---|
host |
str |
field(default_factory=resolve_redis_host) |
port |
int |
field(default_factory=resolve_redis_port) |
db |
int |
0 |
password |
str | None |
None |
socket_timeout |
float |
5.0 |
AuditEvent (dataclass)
Decorators: dataclass, traces(spec='003', story='US-009', reqs=['REQ-003-US-009-001']) · Line 133
Audit event to be recorded in graph.
| Attribute | Type | Default |
|---|---|---|
event_type |
AuditEventType |
|
session_id |
str |
|
volk_id |
str |
|
timestamp |
datetime |
|
data |
dict[str, Any] |
|
outcome |
DecisionOutcome | None |
None |
reason |
str | None |
None |
correlation_id |
str | None |
None |
PolicyDecision (dataclass)
Decorators: dataclass, traces(spec='003', story='US-009', reqs=['REQ-003-US-009-001']) · Line 152
Record of a governance decision.
| Attribute | Type | Default |
|---|---|---|
decision_id |
str |
|
session_id |
str |
|
volk_id |
str |
|
intent |
str |
|
tool |
str |
|
outcome |
DecisionOutcome |
|
reason |
str |
|
timestamp |
datetime |
|
prolog_query |
str | None |
None |
prolog_result |
str | None |
None |
cached |
bool |
False |
GraphAuditTrail
Decorators: traces(spec='003', story='US-010', reqs=['REQ-003-US-010-006', 'REQ-003-US-010-018', 'REQ-003-US-003-010', 'REQ-003-US-… · Line 178
Manages audit trail in graph for Orchestrata.
| Attribute | Type | Default |
|---|---|---|
_EVENT_LABELS |
dict[str, str] |
{'violation': 'Violation', 'escalation': 'Escalation', 'res… |
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(config: GraphAuditConfig | None = None, redis_client: redis.Redis | None = None, redis_config: RedisConfig | None = None) |
Initialize graph audit trail. |
start |
async start() -> None |
Initialize driver and start background consumer. |
_create_redis_client |
_create_redis_client() -> redis.Redis | None |
Create an async Redis client from config / environment defaults. |
stop |
async stop() -> None |
Stop consumer and close connections, draining queue first. |
_enqueue |
async _enqueue(event_type: str, data: dict) -> bool |
Push event to Redis queue (fire-and-forget). |
_consumer_loop |
async _consumer_loop() -> None |
Background task: drain Redis queue, write to graph. |
_write_event |
async _write_event(event: dict[str, Any]) -> bool |
Write event to graph and link to its Session. |
log_violation |
async log_violation(violation: dict) -> None |
Log a violation event. |
log_escalation |
async log_escalation(result: EscalationResult) -> None |
Log an escalation event. |
log_resolution |
async log_resolution(escalation_id: str, resolution: Any) -> None |
Log a resolution event. |
log_timeout |
async log_timeout(escalation_id: str) -> None |
Log a timeout event. |
log_event |
async log_event(event: AuditEvent) -> bool |
Log an audit event asynchronously. |
log_policy_decision |
async log_policy_decision(decision: PolicyDecision) -> None |
Log policy decision event. |
log_session_start |
async log_session_start(session_id: str, volk_id: str, goals: list[str], context: dict) -> None |
Log session start event. |
log_session_end |
async log_session_end(session_id: str, volk_id: str, outcome: str, stats: dict) -> None |
Log session end event. |
log_tool_call |
async log_tool_call(session_id: str, volk_id: str, tool: str, args: dict, result: Any, duration_ms: int) -> None |
Log tool call event. |
get_escalations |
async get_escalations(limit: int = 100) -> list[dict] |
Query escalation nodes from graph. |
get_violations |
async get_violations(limit: int = 100) -> list[dict] |
Query violation nodes from graph. |
get_resolutions |
async get_resolutions(limit: int = 100) -> list[dict] |
Query resolution nodes from graph. |
query_audit_trail |
async query_audit_trail(filters: dict) -> list[dict] |
Query audit trail with filters. |
health_check |
async health_check() -> bool |
Check graph connection health. |
get_dropped_events_count |
get_dropped_events_count() -> int |
Get the count of dropped events due to Redis unavailability. |
close |
async close() -> None |
Close graph connections. |
aclose |
async aclose() -> None |
Async close alias for close(). |
Functions
| Function | Signature | Summary |
|---|---|---|
_project_slug traces |
_project_slug() -> str |
Resolve the root :Project slug this writer's hub edge MERGEs onto. |
_utc_now traces |
_utc_now() -> datetime |
Get current UTC time with timezone info. |
Source
# mypy: ignore-errors
"""Graph audit trail for Orchestrata.
Handles cold storage for audit trail, relationships, and compliance.
Based on INFRASTRUCTURE.md specifications.
ASYNC REWRITE: Uses AsyncGraphDatabase + Redis queue for durable fire-and-forget logging.
"""
# Spec: 008 - Knowledge Graph
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from collections.abc import Awaitable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from typing import TYPE_CHECKING, Any, cast
import redis.asyncio as redis
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.graph import (
AsyncGraphRepository,
GraphRepositoryConfig,
create_async_graph_repository,
)
from orchestrata_ultimate.core.graph_factory import resolve_kg_database, resolve_uri
from orchestrata_ultimate.core.redis_factory import (
resolve_redis_host,
resolve_redis_port,
)
from orchestrata_ultimate.knowledge_graph import canonical_schema as cs
if TYPE_CHECKING:
from orchestrata_ultimate.governance.escalation_handler import EscalationResult
# Module-level logger
_logger = logging.getLogger(__name__)
# Redis queue key for audit events
QUEUE_KEY = "orchestrata:graph:audit:queue"
_DEFAULT_PROJECT_SLUG = "orchestrata-ultimate"
@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001"])
def _project_slug() -> str:
"""Resolve the root ``:Project`` slug this writer's hub edge MERGEs onto.
Read at call time, never at import (Key Rule / RUL-003): the test suite and
the CLI both override ``ORCHESTRATA_PROJECT_ID`` per process.
"""
return os.environ.get("ORCHESTRATA_PROJECT_ID", _DEFAULT_PROJECT_SLUG)
@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001"])
def _utc_now() -> datetime:
"""Get current UTC time with timezone info."""
return datetime.now(UTC)
@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001"])
class AuditEventType(Enum):
"""Types of audit events."""
VIOLATION = "violation"
ESCALATION = "escalation"
RESOLUTION = "resolution"
TIMEOUT = "timeout"
EVENT = "event"
POLICY_DECISION = "policy_decision"
SESSION_START = "session_start"
SESSION_END = "session_end"
TOOL_CALL = "tool_call"
VOLK_LIFECYCLE = "volk_lifecycle"
ERROR = "error"
COMPLIANCE_CHECK = "compliance_check"
INTENT_RECORDED = "intent_recorded"
@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001"])
class DecisionOutcome(Enum):
"""Governance decision outcomes."""
ALLOWED = "allowed"
DENIED = "denied"
ESCALATED = "escalated"
PENDING = "pending"
@dataclass
@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001"])
class GraphAuditConfig:
"""Graph connection configuration."""
uri: str = field(default_factory=resolve_uri)
# No credentials: NEO4J_USERNAME/NEO4J_PASSWORD were retired with the Neo4j
# containers on 2026-07-27 and every remaining endpoint negotiates no auth.
username: str = ""
password: str = ""
# "orchestrata" is not a database on this cluster — see the identical
# defect already recorded in kernel/audit/model.py. Resolved from
# KG_DATABASE so an unset config raises instead of silently opening (and
# writing to) a database of the wrong name.
database: str = field(default_factory=resolve_kg_database)
max_connection_lifetime: int = 3600
max_connection_pool_size: int = 50
@dataclass
@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001"])
class RedisConfig:
"""Redis connection configuration."""
# No literal endpoint default (RUL-003/RUL-011): "localhost:6379" would
# connect to nothing when REDIS_HOST/PORT aren't the target and the
# resulting refusal reads as an outage instead of the config gap it is.
host: str = field(default_factory=resolve_redis_host)
port: int = field(default_factory=resolve_redis_port)
db: int = 0
password: str | None = None
socket_timeout: float = 5.0
@dataclass
@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001"])
class AuditEvent:
"""Audit event to be recorded in graph."""
event_type: AuditEventType
session_id: str
volk_id: str
timestamp: datetime
data: dict[str, Any]
outcome: DecisionOutcome | None = None
reason: str | None = None
correlation_id: str | None = None
# Type alias for queue operations
AuditEntry = AuditEvent
@dataclass
@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001"])
class PolicyDecision:
"""Record of a governance decision."""
decision_id: str
session_id: str
volk_id: str
intent: str
tool: str
outcome: DecisionOutcome
reason: str
timestamp: datetime
prolog_query: str | None = None
prolog_result: str | None = None
cached: bool = False
@traces(
spec="003",
story="US-010",
reqs=[
"REQ-003-US-010-006",
"REQ-003-US-010-018",
"REQ-003-US-003-010",
"REQ-003-US-003-022",
],
)
class GraphAuditTrail:
"""Manages audit trail in graph for Orchestrata.
Uses Redis queue for durable async logging with background consumer.
"""
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-018"])
def __init__(
self,
config: GraphAuditConfig | None = None,
redis_client: redis.Redis | None = None,
redis_config: RedisConfig | None = None,
):
"""Initialize graph audit trail.
Args:
config: Graph configuration. Uses defaults if not provided.
redis_client: Optional Redis client for queueing events. When not
provided, a real async client is created in ``start()`` from
``redis_config`` (or environment defaults), so audit events are
durably enqueued rather than silently dropped.
redis_config: Optional Redis connection configuration used to build
the client when ``redis_client`` is not injected.
"""
self.config = config or GraphAuditConfig()
self._repo: AsyncGraphRepository | None = None
self._redis: redis.Redis | None = redis_client
# When no explicit RedisConfig is given, the internally-created client
# reads connection details from the environment (REDIS_HOST/PORT/DB)
# via redis_factory, mirroring fact_store.
self._redis_config = redis_config
# Whether _redis was created internally (and must be closed by us).
self._owns_redis = False
self._consumer_task: asyncio.Task | None = None
self._running = False
self._closed = False
self._dropped_events = 0
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-018"])
async def start(self) -> None:
"""Initialize driver and start background consumer."""
if self._running:
return
self._repo = create_async_graph_repository(
GraphRepositoryConfig(
uri=self.config.uri,
auth=(self.config.username, self.config.password),
)
)
# Wire a real async Redis client when none was injected so audit
# events are actually enqueued instead of dropped (the no-redis path
# silently increments _dropped_events).
if self._redis is None:
self._redis = self._create_redis_client()
self._owns_redis = self._redis is not None
self._running = True
self._consumer_task = asyncio.create_task(self._consumer_loop())
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-018"])
def _create_redis_client(self) -> redis.Redis | None:
"""Create an async Redis client from config / environment defaults.
Mirrors fact_store via the shared redis_factory so connection handling
is consistent. Returns None if the client cannot be constructed.
"""
try:
from orchestrata_ultimate.core.redis_factory import create_async_redis
cfg = self._redis_config
return create_async_redis(
host=cfg.host if cfg else None,
port=cfg.port if cfg else None,
db=cfg.db if cfg else None,
password=cfg.password if cfg else None,
decode_responses=True,
)
except Exception as e: # pragma: no cover - defensive
_logger.error(f"Failed to create Redis client for audit trail: {e}")
return None
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-018"])
async def stop(self) -> None:
"""Stop consumer and close connections, draining queue first."""
self._running = False
if self._consumer_task:
self._consumer_task.cancel()
try:
await self._consumer_task
except asyncio.CancelledError:
_logger.debug("Graph audit consumer task cancelled")
# Drain remaining queue items
if self._redis:
while True:
# redis-py's command mixins are shared between the sync and
# async clients, so `rpop()` is stub-typed as the union
# `Awaitable[T] | T`; `self._redis` here is always the async
# client, so it always actually returns an awaitable.
result = await cast("Awaitable[Any]", self._redis.rpop(QUEUE_KEY))
if not result:
break
try:
event = json.loads(result)
await self._write_event(event)
except Exception as e:
_logger.error(f"Error draining queue: {e}")
if self._repo:
await self._repo.close()
self._repo = None
# Close the Redis client only if we created it; never close a
# caller-injected client.
if self._owns_redis and self._redis is not None:
try:
await self._redis.aclose()
except Exception as e: # pragma: no cover - defensive
_logger.debug(f"Error closing audit Redis client: {e}")
self._redis = None
self._owns_redis = False
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-006"])
async def _enqueue(self, event_type: str, data: dict) -> bool:
"""Push event to Redis queue (fire-and-forget)."""
if not self._redis:
self._dropped_events += 1
return False
event = {
"event_type": event_type,
"timestamp": _utc_now().isoformat(),
"data": data,
}
# Stub deficiency (see stop()'s rpop cast).
await cast("Awaitable[Any]", self._redis.lpush(QUEUE_KEY, json.dumps(event)))
return True
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-006"])
async def _consumer_loop(self) -> None:
"""Background task: drain Redis queue, write to graph."""
if not self._redis:
return
while self._running:
try:
# Stub deficiency (see stop()'s rpop cast).
result = await cast(
"Awaitable[Any]", self._redis.brpop(QUEUE_KEY, timeout=1)
)
if result:
_, event_json = result
event = json.loads(event_json)
await self._write_event(event)
except asyncio.CancelledError:
break
except Exception as e:
_logger.error(f"Consumer error: {e}")
await asyncio.sleep(1)
# Map event_type string → graph node label.
_EVENT_LABELS: dict[str, str] = {
"violation": "Violation",
"escalation": "Escalation",
"resolution": "Resolution",
"timeout": "Timeout",
"event": "Event",
"policy_decision": "PolicyDecision",
"session_start": "SessionStart",
"session_end": "SessionEnd",
"tool_call": "ToolCall",
"volk_lifecycle": "VolkLifecycle",
"error": "Error",
"compliance_check": "ComplianceCheck",
}
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-006"])
async def _write_event(self, event: dict[str, Any]) -> bool:
"""Write event to graph and link to its Session.
Creates the event node with data, timestamp, and session_id
as first-class properties, then links to the Session node
(creating it lazily via MERGE if it doesn't exist yet).
Returns:
True if the write landed, False if the graph is unavailable,
the event type is unrecognized, or the write itself failed.
Non-fatal either way — the audit trail is a side-channel, never
a reason to break the caller.
"""
if not self._repo:
return False
event_type = event.get("event_type")
label = self._EVENT_LABELS.get(event_type) if event_type is not None else None
if not label:
_logger.warning("Unknown event type: %s", event_type)
return False
data = event.get("data", {})
data_json = json.dumps(data)
ts = event.get("timestamp")
raw_session_id = data.get("session_id", "")
# When the emitter provides no session_id (e.g. log_timeout/log_resolution
# only carry escalation_id), derive a session from escalation_id so the
# event is still reachable via the canonical IN_SESSION + HAS_SESSION
# path. This avoids minting zero-degree orphans and avoids inventing a
# non-canonical HAS_AUDIT_EVENT edge (RUL-016).
session_id = raw_session_id or data.get("escalation_id", "")
if not session_id:
_logger.warning(
"Audit event %s has no session_id or escalation_id; "
"writing without session anchor",
event_type,
)
# Without the canonical key (canonical_schema.CANONICAL_MERGE_KEYS)
# these nodes fail the kg_schema conformance check as unkeyed
# pollution. Most event labels key on `id`, but not all — Resolution
# keys on `resolution_id` (the escalation resolver's natural id), and
# writing audit Resolutions keyed only on `id` split the label across
# two identities (D3, plans/hub-link-plan.md). Stamp the label's
# declared key alongside `id` whenever it differs.
event_id = str(uuid.uuid4())
key_prop = cs.CANONICAL_MERGE_KEYS.get(label, "id")
key_fragment = f", {key_prop}: $id" if key_prop != "id" else ""
project = _project_slug()
try:
if session_id:
await self._repo.execute_write(
f"CREATE (n:{label} {{id: $id{key_fragment}, data: $data, "
f" timestamp: $ts, session_id: $sid}}) "
f"WITH n "
f"MERGE (s:{cs.LABEL_SESSION} {{session_id: $sid}}) "
f"MERGE (n)-[:IN_SESSION]->(s) "
f"WITH s "
f"MERGE (p:{cs.LABEL_PROJECT} {{slug: $project}}) "
f"MERGE (p)-[:{cs.REL_HAS_SESSION}]->(s)",
{
"id": event_id,
"data": data_json,
"ts": ts,
"sid": session_id,
"project": project,
},
)
else:
await self._repo.execute_write(
f"CREATE (n:{label} {{id: $id{key_fragment}, "
f"data: $data, timestamp: $ts}})",
{"id": event_id, "data": data_json, "ts": ts},
)
return True
except Exception as e:
_logger.error("Failed to write %s event: %s", event_type, e)
return False
@traces(
spec="003",
story="US-010",
reqs=[
"REQ-003-US-010-006",
"REQ-003-US-010-018",
"REQ-003-US-010-024",
],
)
async def log_violation(self, violation: dict) -> None:
"""Log a violation event."""
await self._enqueue("violation", violation)
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-024"])
async def log_escalation(self, result: EscalationResult) -> None:
"""Log an escalation event."""
data = {
"escalation_id": result.escalation_id,
"conflict_id": result.conflict.conflict_id,
"conflict_type": result.conflict.conflict_type.value,
"resolved": getattr(result, "resolved", result.resolved_at is not None),
"resolution": result.resolution,
"total_time_ms": getattr(result, "total_time_ms", 0),
}
await self._enqueue("escalation", data)
@traces(spec="001", story="US-003", reqs=["REQ-001-US-005-024"])
async def log_resolution(self, escalation_id: str, resolution: Any) -> None:
"""Log a resolution event."""
data = {
"escalation_id": escalation_id,
"resolution_text": resolution.resolution_text,
"resolved_by": resolution.resolved_by,
}
await self._enqueue("resolution", data)
@traces(spec="001", story="US-003", reqs=["REQ-001-US-003-025"])
async def log_timeout(self, escalation_id: str) -> None:
"""Log a timeout event."""
await self._enqueue("timeout", {"escalation_id": escalation_id})
@traces(
spec="003",
story="US-010",
reqs=[
"REQ-003-US-010-006",
"REQ-003-US-010-010",
"REQ-003-US-010-018",
"REQ-003-US-010-023",
"REQ-003-US-010-036",
"REQ-003-US-010-040",
],
)
async def log_event(self, event: AuditEvent) -> bool:
"""Log an audit event asynchronously."""
return await self._enqueue(
"event",
{
"event_type": event.event_type.value,
"session_id": event.session_id,
"volk_id": event.volk_id,
"timestamp": event.timestamp.isoformat(),
"data": event.data,
"outcome": event.outcome.value if event.outcome else None,
"reason": event.reason,
"correlation_id": event.correlation_id,
},
)
@traces(
spec="003",
story="US-003",
reqs=[
"REQ-003-US-003-010",
"REQ-003-US-003-022",
"REQ-003-US-003-034",
"REQ-003-US-005-005",
],
)
async def log_policy_decision(self, decision: PolicyDecision) -> None:
"""Log policy decision event."""
await self._enqueue(
"policy_decision",
{
"decision_id": decision.decision_id,
"session_id": decision.session_id,
"volk_id": decision.volk_id,
"intent": decision.intent,
"tool": decision.tool,
"outcome": decision.outcome.value,
"reason": decision.reason,
"timestamp": decision.timestamp.isoformat(),
"prolog_query": decision.prolog_query,
"prolog_result": decision.prolog_result,
"cached": decision.cached,
},
)
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-006"])
async def log_session_start(
self, session_id: str, volk_id: str, goals: list[str], context: dict
) -> None:
"""Log session start event."""
await self._enqueue(
"session_start",
{
"session_id": session_id,
"volk_id": volk_id,
"goals": goals,
"context": context,
},
)
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-006"])
async def log_session_end(
self, session_id: str, volk_id: str, outcome: str, stats: dict
) -> None:
"""Log session end event."""
await self._enqueue(
"session_end",
{
"session_id": session_id,
"volk_id": volk_id,
"outcome": outcome,
"stats": stats,
},
)
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-006"])
async def log_tool_call(
self,
session_id: str,
volk_id: str,
tool: str,
args: dict,
result: Any,
duration_ms: int,
) -> None:
"""Log tool call event."""
await self._enqueue(
"tool_call",
{
"session_id": session_id,
"volk_id": volk_id,
"tool": tool,
"args": args,
"result": str(result),
"duration_ms": duration_ms,
},
)
@traces(spec="001", story="US-003", reqs=["REQ-001-US-003-024"])
async def get_escalations(self, limit: int = 100) -> list[dict]:
"""Query escalation nodes from graph."""
if not self._repo:
return []
rows = await self._repo.execute_read(
"MATCH (e:Escalation) RETURN e ORDER BY e.timestamp DESC LIMIT $limit",
{"limit": limit},
)
return [json.loads(r["e"]["data"]) for r in rows]
@traces(spec="007", story="FR-PGOV-007", reqs=["REQ-007-FR7-008"])
async def get_violations(self, limit: int = 100) -> list[dict]:
"""Query violation nodes from graph."""
if not self._repo:
return []
rows = await self._repo.execute_read(
"MATCH (v:Violation) RETURN v ORDER BY v.timestamp DESC LIMIT $limit",
{"limit": limit},
)
return [json.loads(r["v"]["data"]) for r in rows]
@traces(spec="001", story="US-003", reqs=["REQ-001-US-003-024"])
async def get_resolutions(self, limit: int = 100) -> list[dict]:
"""Query resolution nodes from graph."""
if not self._repo:
return []
rows = await self._repo.execute_read(
"MATCH (r:Resolution) RETURN r ORDER BY r.resolved_at DESC LIMIT $limit",
{"limit": limit},
)
resolutions = []
for r in rows:
node = dict(r["r"])
if "data" in node:
resolutions.append(json.loads(node["data"]))
else:
resolutions.append(node)
return resolutions
@traces(
spec="003",
story="US-010",
reqs=["REQ-003-US-010-022", "REQ-003-US-010-037"],
)
async def query_audit_trail(self, filters: dict) -> list[dict]:
"""Query audit trail with filters."""
if not self._repo:
return []
# Build query based on filters
node_type = filters.get("event_type", "Event")
session_id = filters.get("session_id")
limit = filters.get("limit", 100)
query = f"MATCH (n:{node_type})"
params = {"limit": limit}
if session_id:
query += " WHERE n.data CONTAINS $session_id"
params["session_id"] = session_id
query += " RETURN n ORDER BY n.timestamp DESC LIMIT $limit"
rows = await self._repo.execute_read(query, params)
return [json.loads(r["n"]["data"]) for r in rows]
async def health_check(self) -> bool:
"""Check graph connection health."""
if self._closed or not self._repo:
return False
try:
await self._repo.execute_read("RETURN 1")
return True
except Exception as e:
_logger.error(f"Graph health check failed: {e}")
return False
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-023"])
def get_dropped_events_count(self) -> int:
"""Get the count of dropped events due to Redis unavailability.
Returns:
Number of dropped events.
"""
return self._dropped_events
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-018"])
async def close(self) -> None:
"""Close graph connections."""
self._closed = True
await self.stop()
@traces(spec="003", story="US-010", reqs=["REQ-003-US-010-018"])
async def aclose(self) -> None:
"""Async close alias for close()."""
await self.close()