From 914b698af7d656fb5da644d4ebf3294206970a61 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 15 May 2026 11:15:16 +0200 Subject: [PATCH] feat(memory): add audit export surface --- docs/memory-graph-runtime.md | 11 +- src/kontextual_engine/__init__.py | 4 + src/kontextual_engine/services/__init__.py | 4 + .../services/memory_service.py | 352 +++++++++++++----- tests/test_memory_graph_runtime.py | 49 +++ ...NT-WP-0017-agentic-memory-graph-runtime.md | 8 +- 6 files changed, 333 insertions(+), 95 deletions(-) diff --git a/docs/memory-graph-runtime.md b/docs/memory-graph-runtime.md index 9be668d..10f7d51 100644 --- a/docs/memory-graph-runtime.md +++ b/docs/memory-graph-runtime.md @@ -21,10 +21,10 @@ with `markitect-tool`. - `InMemoryMemoryGraphRepository` provides deterministic local storage for tests and future service wiring. - `MemoryRuntimeService.import_markitect_graph()` persists an imported graph and - can attach an audit event when an `OperationContext` is supplied. + persists an audit event when an `OperationContext` is supplied. - `MemoryRuntimeService.query_memory()` retrieves graph nodes through a scope policy check plus per-node policy checks, returns source-grounded context - items, preserves safe denied diagnostics, and emits an audit event in the + items, preserves safe denied diagnostics, and persists an audit event in the result envelope. - `MemoryRuntimeService.apply_retention()` marks stale memories for review or transitions old memories to `delete_requested` without physical deletion. @@ -40,6 +40,12 @@ with `markitect-tool`. - `MemoryRuntimeService.export_context_package_inputs()` emits Markitect-compatible context package input envelopes without invoking the Markitect compiler. +- `MemoryGraphRepository` persists memory audit events separately from + Markitect memory events, allowing operations to be queried by graph, + correlation id, and operation. +- `MemoryRuntimeService.export_runtime_envelope()` emits a portable runtime + envelope containing graph nodes, edges, memory events, and audit traces with + operation id, actor, policy decision, and filter metadata. ## Boundary @@ -53,6 +59,7 @@ with `markitect-tool`. - runtime ids and persistence - append-only event storage +- durable audit event storage - permission-aware retrieval and context assembly - retention, refresh, compaction, review gates, and audit behavior - agent-safe update plans and Markitect-compatible export envelopes diff --git a/src/kontextual_engine/__init__.py b/src/kontextual_engine/__init__.py index a85c30d..7ce2fea 100644 --- a/src/kontextual_engine/__init__.py +++ b/src/kontextual_engine/__init__.py @@ -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", diff --git a/src/kontextual_engine/services/__init__.py b/src/kontextual_engine/services/__init__.py index 27c0755..8105bde 100644 --- a/src/kontextual_engine/services/__init__.py +++ b/src/kontextual_engine/services/__init__.py @@ -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", diff --git a/src/kontextual_engine/services/memory_service.py b/src/kontextual_engine/services/memory_service.py index 3214deb..101a2b6 100644 --- a/src/kontextual_engine/services/memory_service.py +++ b/src/kontextual_engine/services/memory_service.py @@ -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, diff --git a/tests/test_memory_graph_runtime.py b/tests/test_memory_graph_runtime.py index 2c10cf6..15d4f79 100644 --- a/tests/test_memory_graph_runtime.py +++ b/tests/test_memory_graph_runtime.py @@ -13,6 +13,7 @@ from kontextual_engine import ( MemoryRefreshRequest, MemoryRetentionRequest, MemoryQueryRequest, + MemoryRuntimeExportRequest, MemoryRuntimeService, MemoryUpdateRequest, OperationContext, @@ -73,6 +74,7 @@ def test_memory_runtime_service_imports_contracts_and_reports_audit_context() -> assert summary.audit_event is not None assert summary.audit_event.actor_id == "agent-codex" assert summary.audit_event.correlation_id == "corr-memory" + assert repo.get_memory_audit_event(summary.audit_event.event_id) == summary.audit_event assert repo.get_memory_profile(summary.profile_id).memory_kinds == ( "reasoning", "knowledge", @@ -283,6 +285,53 @@ def test_memory_package_export_emits_markitect_context_package_inputs_without_de assert export.audit_event.operation == "memory.package.export" +def test_memory_runtime_export_includes_persisted_audit_and_event_traces() -> None: + repo = InMemoryMemoryGraphRepository() + service = MemoryRuntimeService(repo) + context = operation_context() + summary = service.import_markitect_graph(_graph_contract(), context=context) + + query = service.query_memory(MemoryQueryRequest(graph_id=summary.graph_id), context) + retention = service.apply_retention( + MemoryRetentionRequest(graph_id=summary.graph_id, stale_after_days=0), + context, + ) + audit_operations = { + event.operation + for event in repo.list_memory_audit_events( + graph_id=summary.graph_id, + correlation_id=context.correlation_id, + ) + } + export = service.export_runtime_envelope( + MemoryRuntimeExportRequest( + graph_id=summary.graph_id, + event_kinds=("recorded", "retention"), + operations=("memory.query", "memory.retention.apply"), + ), + context, + ) + + assert query.success is True + assert retention.appended_events[0].kind == "retention" + assert {"memory.import_markitect_graph", "memory.query", "memory.retention.apply"} <= audit_operations + assert export.success is True + assert export.envelope["schema_version"] == "kontextual.memory.runtime-export.v1" + assert export.envelope["metadata"]["counts"] == { + "nodes": 2, + "edges": 1, + "events": 2, + "audit_events": 2, + } + assert {event["kind"] for event in export.envelope["events"]} == {"recorded", "retention"} + assert {event["operation"] for event in export.envelope["audit_events"]} == { + "memory.query", + "memory.retention.apply", + } + assert export.audit_event is not None + assert repo.get_memory_audit_event(export.audit_event.event_id) == export.audit_event + + def test_memory_runtime_service_rejects_invalid_edge_contracts() -> None: repo = InMemoryMemoryGraphRepository() service = MemoryRuntimeService(repo) diff --git a/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md b/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md index 54a585c..0a0df7c 100644 --- a/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md +++ b/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md @@ -98,6 +98,12 @@ review gates for durable writes, source/policy explanations on planned node updates, and Markitect-compatible context package input export. The export method emits package inputs only; Markitect remains the package compiler. +The observability/export slice is implemented. Memory audit events are now +persisted through the memory graph repository, queryable by graph, correlation, +and operation. Runtime export envelopes include graph nodes, edges, memory +events, audit traces, operation id, actor metadata, policy decisions, and +filter metadata for portable inspection or handoff. + ## P17.1 - Import and map Markitect memory contracts ```task @@ -215,7 +221,7 @@ Output: API contracts, tests, and agent-safe operation notes. ```task id: KONT-WP-0017-T006 -status: todo +status: done priority: medium state_hub_task_id: "54f51f87-6420-4170-913f-f6dae098fe71" ```