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

@@ -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

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,

View File

@@ -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)

View File

@@ -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"
```