feat(memory): add audit export surface

This commit is contained in:
2026-05-15 11:15:16 +02:00
parent c5110f61b0
commit 914b698af7
6 changed files with 333 additions and 95 deletions

View File

@@ -148,6 +148,8 @@ from .services import (
MemoryRetrievalItem,
MemoryRetrievalResult,
MemoryRetentionRequest,
MemoryRuntimeExportRequest,
MemoryRuntimeExportResult,
MemoryRuntimeService,
MemoryUpdatePlan,
MemoryUpdateRequest,
@@ -288,6 +290,8 @@ __all__ = [
"MemoryRetrievalItem",
"MemoryRetrievalResult",
"MemoryRetentionRequest",
"MemoryRuntimeExportRequest",
"MemoryRuntimeExportResult",
"MemoryRuntimeService",
"MemorySourceSpan",
"MemoryUpdatePlan",

View File

@@ -21,6 +21,8 @@ from .memory_service import (
MemoryRetrievalItem,
MemoryRetrievalResult,
MemoryRetentionRequest,
MemoryRuntimeExportRequest,
MemoryRuntimeExportResult,
MemoryRuntimeService,
MemoryUpdatePlan,
MemoryUpdateRequest,
@@ -85,6 +87,8 @@ __all__ = [
"MemoryRetrievalItem",
"MemoryRetrievalResult",
"MemoryRetentionRequest",
"MemoryRuntimeExportRequest",
"MemoryRuntimeExportResult",
"MemoryRuntimeService",
"MemoryUpdatePlan",
"MemoryUpdateRequest",

View File

@@ -537,18 +537,20 @@ class MemoryRuntimeService:
audit_event = None
if context:
audit_event = AuditEvent.from_context(
"memory.import_markitect_graph",
f"memory-graph:{imported.graph_id}",
AuditOutcome.SUCCESS,
context,
details={
"contract_graph_id": imported.contract_graph_id,
"profile_id": imported.profile.profile_id if imported.profile else None,
"nodes": len(imported.nodes),
"edges": len(imported.edges),
"events": len(imported.events),
},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.import_markitect_graph",
f"memory-graph:{imported.graph_id}",
AuditOutcome.SUCCESS,
context,
details={
"contract_graph_id": imported.contract_graph_id,
"profile_id": imported.profile.profile_id if imported.profile else None,
"nodes": len(imported.nodes),
"edges": len(imported.edges),
"events": len(imported.events),
},
)
)
return MemoryGraphImportSummary(
graph_id=imported.graph_id,
@@ -588,13 +590,15 @@ class MemoryRuntimeService:
resource_metadata={"query": request.to_dict()},
)
if not scope_decision.allowed:
audit_event = AuditEvent.from_context(
"memory.query",
scope_resource,
AuditOutcome.DENIED,
context,
policy_decision=scope_decision,
details={"query": request.to_dict()},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.query",
scope_resource,
AuditOutcome.DENIED,
context,
policy_decision=scope_decision,
details={"query": request.to_dict()},
)
)
return MemoryRetrievalResult(
request=request,
@@ -653,18 +657,20 @@ class MemoryRuntimeService:
total = len(items)
page = tuple(items[request.offset : request.offset + request.limit])
outcome = AuditOutcome.PARTIAL if denied_count else AuditOutcome.SUCCESS
audit_event = AuditEvent.from_context(
"memory.query",
scope_resource,
outcome,
context,
policy_decision=scope_decision,
details={
"query": request.to_dict(),
"matched_count": len(nodes),
"permission_filtered_count": denied_count,
"result_count": len(page),
},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.query",
scope_resource,
outcome,
context,
policy_decision=scope_decision,
details={
"query": request.to_dict(),
"matched_count": len(nodes),
"permission_filtered_count": denied_count,
"result_count": len(page),
},
)
)
return MemoryRetrievalResult(
request=request,
@@ -737,16 +743,18 @@ class MemoryRuntimeService:
node_updates=[update.to_dict() for update in updates],
metadata={"request": request.to_dict()},
)
audit_event = AuditEvent.from_context(
"memory.retention.apply",
f"memory-graph:{request.graph_id}",
AuditOutcome.DRY_RUN if request.dry_run else AuditOutcome.SUCCESS,
context,
details={
"request": request.to_dict(),
"updated_nodes": len(updates),
"event_id": event.event_id if event else None,
},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.retention.apply",
f"memory-graph:{request.graph_id}",
AuditOutcome.DRY_RUN if request.dry_run else AuditOutcome.SUCCESS,
context,
details={
"request": request.to_dict(),
"updated_nodes": len(updates),
"event_id": event.event_id if event else None,
},
)
)
return MemoryLifecycleResult(
operation="memory.retention.apply",
@@ -797,16 +805,18 @@ class MemoryRuntimeService:
node_updates=[update.to_dict() for update in updates],
metadata={"request": request.to_dict()},
)
audit_event = AuditEvent.from_context(
"memory.refresh",
f"memory-graph:{request.graph_id}",
AuditOutcome.DRY_RUN if request.dry_run else AuditOutcome.SUCCESS,
context,
details={
"request": request.to_dict(),
"updated_nodes": len(updates),
"event_id": event.event_id if event else None,
},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.refresh",
f"memory-graph:{request.graph_id}",
AuditOutcome.DRY_RUN if request.dry_run else AuditOutcome.SUCCESS,
context,
details={
"request": request.to_dict(),
"updated_nodes": len(updates),
"event_id": event.event_id if event else None,
},
)
)
return MemoryLifecycleResult(
operation="memory.refresh",
@@ -891,17 +901,19 @@ class MemoryRuntimeService:
"operation_id": operation_id,
},
)
audit_event = AuditEvent.from_context(
"memory.compact",
f"memory-graph:{request.graph_id}",
AuditOutcome.DRY_RUN if request.dry_run else AuditOutcome.SUCCESS,
context,
details={
"request": request.to_dict(),
"summary_node_id": summary.node_id,
"source_nodes": len(nodes),
"event_id": event.event_id if event else None,
},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.compact",
f"memory-graph:{request.graph_id}",
AuditOutcome.DRY_RUN if request.dry_run else AuditOutcome.SUCCESS,
context,
details={
"request": request.to_dict(),
"summary_node_id": summary.node_id,
"source_nodes": len(nodes),
"event_id": event.event_id if event else None,
},
)
)
return MemoryLifecycleResult(
operation="memory.compact",
@@ -979,12 +991,14 @@ class MemoryRuntimeService:
"Memory update requires explicit review approval before durable write.",
details={"plan_id": plan.plan_id, "review_decision": review_decision},
)
audit_event = AuditEvent.from_context(
"memory.update.apply",
f"memory-graph:{plan.request.graph_id}",
AuditOutcome.REVIEW_REQUIRED,
context,
details={"plan_id": plan.plan_id, "review_decision": review_decision},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.update.apply",
f"memory-graph:{plan.request.graph_id}",
AuditOutcome.REVIEW_REQUIRED,
context,
details={"plan_id": plan.plan_id, "review_decision": review_decision},
)
)
return MemoryUpdateResult(
plan=plan,
@@ -1001,13 +1015,15 @@ class MemoryRuntimeService:
)
if not decision.allowed:
diagnostic = _permission_denied_diagnostic(decision)
audit_event = AuditEvent.from_context(
"memory.update.apply",
f"memory-graph:{plan.request.graph_id}",
AuditOutcome.DENIED,
context,
policy_decision=decision,
details={"plan_id": plan.plan_id},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.update.apply",
f"memory-graph:{plan.request.graph_id}",
AuditOutcome.DENIED,
context,
policy_decision=decision,
details={"plan_id": plan.plan_id},
)
)
return MemoryUpdateResult(plan=plan, audit_event=audit_event, diagnostics=(diagnostic,), success=False)
for update in plan.planned_updates:
@@ -1019,13 +1035,15 @@ class MemoryRuntimeService:
node_updates=[_planned_update_event_payload(update) for update in plan.planned_updates],
metadata={"plan_id": plan.plan_id, "review_decision": review_decision},
)
audit_event = AuditEvent.from_context(
"memory.update.apply",
f"memory-graph:{plan.request.graph_id}",
AuditOutcome.SUCCESS,
context,
policy_decision=decision,
details={"plan_id": plan.plan_id, "applied_nodes": len(plan.planned_updates), "event_id": event.event_id},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.update.apply",
f"memory-graph:{plan.request.graph_id}",
AuditOutcome.SUCCESS,
context,
policy_decision=decision,
details={"plan_id": plan.plan_id, "applied_nodes": len(plan.planned_updates), "event_id": event.event_id},
)
)
return MemoryUpdateResult(
plan=plan,
@@ -1050,16 +1068,18 @@ class MemoryRuntimeService:
)
package_input = _markitect_package_input(request, retrieval)
outcome = AuditOutcome.PARTIAL if retrieval.metadata.get("permission_filtered_count") else AuditOutcome.SUCCESS
audit_event = AuditEvent.from_context(
"memory.package.export",
f"memory-graph:{request.query.graph_id or '*'}",
outcome,
context,
details={
"request": request.to_dict(),
"items": len(package_input.get("items", ())),
"permission_filtered_count": retrieval.metadata.get("permission_filtered_count", 0),
},
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.package.export",
f"memory-graph:{request.query.graph_id or '*'}",
outcome,
context,
details={
"request": request.to_dict(),
"items": len(package_input.get("items", ())),
"permission_filtered_count": retrieval.metadata.get("permission_filtered_count", 0),
},
)
)
return MemoryPackageExportResult(
request=request,
@@ -1070,6 +1090,132 @@ class MemoryRuntimeService:
diagnostics=retrieval.diagnostics,
)
def export_runtime_envelope(
self,
request: MemoryRuntimeExportRequest,
context: OperationContext,
) -> MemoryRuntimeExportResult:
diagnostics = _validate_runtime_export_request(request)
if diagnostics:
return MemoryRuntimeExportResult(
request=request,
correlation_id=context.correlation_id,
diagnostics=tuple(diagnostics),
success=False,
)
target = f"memory-graph:{request.graph_id}"
decision = self._authorize(
context,
"memory.runtime.export",
target,
resource_metadata={"request": request.to_dict()},
)
if not decision.allowed:
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.runtime.export",
target,
AuditOutcome.DENIED,
context,
policy_decision=decision,
details={"request": request.to_dict()},
)
)
return MemoryRuntimeExportResult(
request=request,
correlation_id=context.correlation_id,
audit_event=audit_event,
diagnostics=(_permission_denied_diagnostic(decision),),
success=False,
)
operation_id = new_id("memexport")
exported_at = utc_now().isoformat()
all_nodes = self.repository.list_memory_nodes(graph_id=request.graph_id)
nodes = _runtime_export_nodes(all_nodes, include_retired=request.include_retired)
visible_node_ids = {str(node.node_id) for node in nodes}
edges: list[MemoryEdgeRecord] = []
if request.include_edges:
edges = self.repository.list_memory_edges(graph_id=request.graph_id)
if not request.include_retired:
edges = [
edge
for edge in edges
if edge.lifecycle not in _runtime_export_hidden_lifecycles()
and edge.source_node_id in visible_node_ids
and edge.target_node_id in visible_node_ids
]
events: list[MemoryEventRecord] = []
if request.include_events:
wanted_kinds = set(request.event_kinds)
events = [
event
for event in self.repository.list_memory_events(graph_id=request.graph_id)
if not wanted_kinds or event.kind in wanted_kinds
]
audit_events: list[AuditEvent] = []
if request.include_audit_events:
wanted_operations = set(request.operations)
audit_events = [
event
for event in self.repository.list_memory_audit_events(
graph_id=request.graph_id,
correlation_id=request.correlation_id,
)
if not wanted_operations or event.operation in wanted_operations
]
export_nodes = nodes if request.include_nodes else []
counts = {
"nodes": len(export_nodes),
"edges": len(edges),
"events": len(events),
"audit_events": len(audit_events),
}
envelope = {
"schema_version": "kontextual.memory.runtime-export.v1",
"operation_id": operation_id,
"graph_id": request.graph_id,
"exported_at": exported_at,
"actor": context.actor.to_dict(),
"correlation_id": context.correlation_id,
"request": request.to_dict(),
"nodes": [node.to_dict() for node in export_nodes],
"edges": [edge.to_dict() for edge in edges],
"events": [event.to_dict() for event in events],
"audit_events": [event.to_dict() for event in audit_events],
"metadata": {
"counts": counts,
"filters": {
"event_kinds": list(request.event_kinds),
"operations": list(request.operations),
"correlation_id": request.correlation_id,
"include_retired": request.include_retired,
},
},
}
audit_event = self._record_audit(
AuditEvent.from_context(
"memory.runtime.export",
target,
AuditOutcome.SUCCESS,
context,
policy_decision=decision,
details={
"operation_id": operation_id,
"graph_id": request.graph_id,
"request": request.to_dict(),
"counts": counts,
},
)
)
return MemoryRuntimeExportResult(
request=request,
correlation_id=context.correlation_id,
envelope=envelope,
audit_event=audit_event,
)
def _authorize(
self,
context: OperationContext,
@@ -1097,6 +1243,9 @@ class MemoryRuntimeService:
},
)
def _record_audit(self, audit_event: AuditEvent) -> AuditEvent:
return self.repository.save_memory_audit_event(audit_event)
def _append_lifecycle_event(
self,
event_kind: str,
@@ -1133,6 +1282,25 @@ def _validate_query(request: MemoryQueryRequest) -> list[Diagnostic]:
return diagnostics
def _validate_runtime_export_request(request: MemoryRuntimeExportRequest) -> list[Diagnostic]:
return _validate_node_selection_request(request.graph_id, ())
def _runtime_export_nodes(
nodes: list[MemoryNodeRecord],
*,
include_retired: bool,
) -> list[MemoryNodeRecord]:
if include_retired:
return nodes
hidden_lifecycles = _runtime_export_hidden_lifecycles()
return [node for node in nodes if node.lifecycle not in hidden_lifecycles]
def _runtime_export_hidden_lifecycles() -> set[LifecycleState]:
return {LifecycleState.RETIRED, LifecycleState.DELETE_REQUESTED, LifecycleState.DELETED}
def _node_policy_metadata(node: MemoryNodeRecord) -> dict[str, Any]:
return {
"node_id": node.node_id,