generated from coulomb/repo-seed
Implement local runtime persistence and policy gates
This commit is contained in:
471
src/phase_memory/runtime.py
Normal file
471
src/phase_memory/runtime.py
Normal file
@@ -0,0 +1,471 @@
|
||||
"""Local runtime facade for deterministic phase-memory operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .activation import activation_action, plan_activation
|
||||
from .adapters import (
|
||||
AllowAllPolicyGateway,
|
||||
InMemoryMemoryEventLog,
|
||||
InMemoryMemoryGraphStore,
|
||||
NoopContextPackageCompiler,
|
||||
RecordingAuditSink,
|
||||
)
|
||||
from .contracts import ContractIngressResult, graph_from_markitect, profile_from_markitect
|
||||
from .lifecycle import plan_compaction, plan_refresh, plan_retention
|
||||
from .models import (
|
||||
Diagnostic,
|
||||
LifecycleAction,
|
||||
LifecycleActionKind,
|
||||
LifecycleState,
|
||||
MemoryGraph,
|
||||
MemoryNode,
|
||||
MemoryPhase,
|
||||
PolicyDecision,
|
||||
ProfileIntent,
|
||||
ReviewRecord,
|
||||
)
|
||||
from .planner import plan_profile_execution
|
||||
from .policy import audit_event, evaluate_activation_node, make_review_record, policy_denial_diagnostic, redacted_node
|
||||
from .ports import AuditSink, ContextPackageCompiler, MemoryEventLog, MemoryGraphStore, PolicyGateway
|
||||
from .utils import compact_dict, stable_digest, to_plain
|
||||
|
||||
RUNTIME_ENVELOPE_SCHEMA = "phase_memory.runtime.envelope.v1"
|
||||
PACKAGE_REQUEST_SCHEMA = "phase_memory.package_request.v1"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseMemoryRuntime:
|
||||
"""Dependency-light local runtime facade.
|
||||
|
||||
The facade coordinates existing pure planners and local adapters while
|
||||
keeping every public operation dry-run and JSON-serializable by default.
|
||||
"""
|
||||
|
||||
graph_store: MemoryGraphStore = field(default_factory=InMemoryMemoryGraphStore)
|
||||
event_log: MemoryEventLog = field(default_factory=InMemoryMemoryEventLog)
|
||||
package_compiler: ContextPackageCompiler = field(default_factory=NoopContextPackageCompiler)
|
||||
policy_gateway: PolicyGateway = field(default_factory=AllowAllPolicyGateway)
|
||||
audit_sink: AuditSink = field(default_factory=RecordingAuditSink)
|
||||
|
||||
def import_profile(self, data: dict[str, Any], *, source_ref: str = "mapping") -> dict[str, Any]:
|
||||
result = profile_from_markitect(data)
|
||||
if result.valid:
|
||||
self.graph_store.save_profile(result.value)
|
||||
return self._contract_envelope("profile.import", result, source_ref=source_ref)
|
||||
|
||||
def import_graph(self, data: dict[str, Any], *, source_ref: str = "mapping") -> dict[str, Any]:
|
||||
result = graph_from_markitect(data)
|
||||
if result.valid:
|
||||
graph: MemoryGraph = result.value
|
||||
for node in graph.nodes:
|
||||
self.graph_store.save_node(node)
|
||||
for edge in graph.edges:
|
||||
self.graph_store.save_edge(edge)
|
||||
for event in graph.events:
|
||||
self.event_log.append(event)
|
||||
return self._contract_envelope("graph.import", result, source_ref=source_ref)
|
||||
|
||||
def plan_profile(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
available_adapters: Iterable[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = profile_from_markitect(data)
|
||||
profile: ProfileIntent | None = result.value if result.valid else None
|
||||
plan = plan_profile_execution(profile, available_adapters=available_adapters) if profile else None
|
||||
diagnostics = list(result.diagnostics)
|
||||
if plan is not None:
|
||||
diagnostics.extend(plan.diagnostics)
|
||||
return self._envelope(
|
||||
"profile.plan",
|
||||
subject_kind="memory_profile",
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid and plan is not None,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"profile": result.value.to_dict() if hasattr(result.value, "to_dict") else result.value,
|
||||
"plan": plan.to_dict() if plan else None,
|
||||
},
|
||||
)
|
||||
|
||||
def plan_lifecycle(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
stale_after_days: int | None = None,
|
||||
delete_after_days: int | None = None,
|
||||
refresh_digests: dict[str, str] | None = None,
|
||||
compact_node_ids: tuple[str, ...] = (),
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = graph_from_markitect(data)
|
||||
graph: MemoryGraph | None = result.value if result.valid else None
|
||||
actions: list[LifecycleAction] = []
|
||||
|
||||
if graph is not None:
|
||||
actions.extend(
|
||||
plan_retention(
|
||||
graph.nodes,
|
||||
stale_after_days=stale_after_days,
|
||||
delete_after_days=delete_after_days,
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
if refresh_digests:
|
||||
actions.extend(plan_refresh(graph.nodes, source_digest_by_node_id=refresh_digests))
|
||||
if compact_node_ids:
|
||||
by_id = graph.node_by_id()
|
||||
compact_nodes = [by_id[node_id] for node_id in compact_node_ids if node_id in by_id]
|
||||
if compact_nodes:
|
||||
actions.append(plan_compaction(compact_nodes))
|
||||
|
||||
return self._envelope(
|
||||
"graph.lifecycle.plan",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid,
|
||||
diagnostics=result.diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"graph_id": result.subject_id,
|
||||
"dry_run_actions": [action.to_dict() for action in actions],
|
||||
"parameters": compact_dict(
|
||||
{
|
||||
"stale_after_days": stale_after_days,
|
||||
"delete_after_days": delete_after_days,
|
||||
"refresh_digests": refresh_digests or {},
|
||||
"compact_node_ids": list(compact_node_ids),
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def plan_activation(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
max_items: int,
|
||||
max_tokens: int,
|
||||
profile_id: str | None = None,
|
||||
priority_node_ids: tuple[str, ...] = (),
|
||||
include_events: bool = True,
|
||||
policy_context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = graph_from_markitect(data)
|
||||
graph: MemoryGraph | None = result.value if result.valid else None
|
||||
policy_denials: list[dict[str, Any]] = []
|
||||
diagnostics = list(result.diagnostics)
|
||||
if graph is not None and policy_context:
|
||||
graph, policy_denials, policy_diagnostics = _policy_filtered_graph(graph, policy_context)
|
||||
diagnostics.extend(policy_diagnostics)
|
||||
plan = (
|
||||
plan_activation(
|
||||
graph,
|
||||
max_items=max_items,
|
||||
max_tokens=max_tokens,
|
||||
profile_id=profile_id,
|
||||
priority_node_ids=priority_node_ids,
|
||||
include_events=include_events,
|
||||
)
|
||||
if graph
|
||||
else None
|
||||
)
|
||||
if plan is not None:
|
||||
diagnostics.extend(plan.diagnostics)
|
||||
|
||||
return self._envelope(
|
||||
"graph.activation.plan",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid and plan is not None,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"activation_plan": plan.to_dict() if plan else None,
|
||||
"activation_action": activation_action(plan).to_dict() if plan else None,
|
||||
"package_request": self.package_request(plan.selection) if plan else None,
|
||||
"policy_denials": policy_denials,
|
||||
},
|
||||
)
|
||||
|
||||
def compile_package(self, selection: dict[str, Any], *, source_ref: str = "selection") -> dict[str, Any]:
|
||||
request = self.package_request(selection)
|
||||
response = self.package_compiler.compile_selection(selection)
|
||||
return self._envelope(
|
||||
"package.compile",
|
||||
subject_kind="memory_selection",
|
||||
subject_id=str(selection.get("id") or ""),
|
||||
valid=True,
|
||||
diagnostics=(),
|
||||
source_ref=source_ref,
|
||||
data={"package_request": request, "package_response": response},
|
||||
)
|
||||
|
||||
def export_graph(self, *, graph_id: str = "local", source_ref: str = "local-store") -> dict[str, Any]:
|
||||
events = self.event_log.list_events()
|
||||
if hasattr(self.graph_store, "export_graph"):
|
||||
graph = self.graph_store.export_graph(graph_id=graph_id, events=events)
|
||||
else:
|
||||
graph = MemoryGraph(graph_id=graph_id, nodes=tuple(self.graph_store.list_nodes()), events=tuple(events))
|
||||
return self._envelope(
|
||||
"graph.export",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=graph.graph_id,
|
||||
valid=True,
|
||||
diagnostics=(),
|
||||
source_ref=source_ref,
|
||||
data={"graph": graph.to_dict()},
|
||||
)
|
||||
|
||||
def repair_diagnostics(self, *, source_ref: str = "local-store") -> dict[str, Any]:
|
||||
event_diagnostics = self.event_log.diagnostics() if hasattr(self.event_log, "diagnostics") else ()
|
||||
events = self.event_log.list_events()
|
||||
store_diagnostics = (
|
||||
self.graph_store.repair_diagnostics(events=events)
|
||||
if hasattr(self.graph_store, "repair_diagnostics")
|
||||
else ()
|
||||
)
|
||||
diagnostics = tuple(event_diagnostics) + tuple(store_diagnostics)
|
||||
return self._envelope(
|
||||
"store.repair.diagnostics",
|
||||
subject_kind="local_store",
|
||||
subject_id=source_ref,
|
||||
valid=not any(diagnostic.severity == "error" for diagnostic in diagnostics),
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={"diagnostic_count": len(diagnostics)},
|
||||
)
|
||||
|
||||
def apply_lifecycle_actions(
|
||||
self,
|
||||
actions: Iterable[LifecycleAction | dict[str, Any]],
|
||||
*,
|
||||
approval_marker: str = "",
|
||||
review_record: ReviewRecord | dict[str, Any] | None = None,
|
||||
source_ref: str = "lifecycle-actions",
|
||||
) -> dict[str, Any]:
|
||||
applied: list[dict[str, Any]] = []
|
||||
denied: list[dict[str, Any]] = []
|
||||
diagnostics: list[Diagnostic] = []
|
||||
|
||||
for raw_action in actions:
|
||||
action = _coerce_action(raw_action)
|
||||
review = _coerce_review(review_record, action=action, approval_marker=approval_marker)
|
||||
if action.requires_review and (
|
||||
review is None or not review.approved or review.reviewed_action_id != _action_review_id(action)
|
||||
):
|
||||
denied.append(
|
||||
{
|
||||
"target_id": action.target_id,
|
||||
"action": action.action.value,
|
||||
"reason": "review_required",
|
||||
}
|
||||
)
|
||||
diagnostics.append(
|
||||
Diagnostic(
|
||||
"warn",
|
||||
"review_required",
|
||||
"Lifecycle action requires an approval marker before apply.",
|
||||
action.target_id,
|
||||
{"action": action.action.value},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
applied.append(self._apply_action(action, approval_marker=approval_marker or (review.review_id if review else "")))
|
||||
|
||||
return self._envelope(
|
||||
"lifecycle.apply",
|
||||
subject_kind="lifecycle_actions",
|
||||
subject_id=f"batch:{stable_digest([applied, denied])}",
|
||||
valid=not denied,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
dry_run=False,
|
||||
data={
|
||||
"applied": applied,
|
||||
"denied": denied,
|
||||
"approval_marker": approval_marker,
|
||||
"review_record": review_record.to_dict() if isinstance(review_record, ReviewRecord) else review_record,
|
||||
},
|
||||
)
|
||||
|
||||
def package_request(self, selection: dict[str, Any]) -> dict[str, Any]:
|
||||
request_id = f"package-request:{stable_digest(selection)}"
|
||||
return {
|
||||
"schema_version": PACKAGE_REQUEST_SCHEMA,
|
||||
"id": request_id,
|
||||
"selection": dict(selection),
|
||||
"compiler": self.package_compiler.__class__.__name__,
|
||||
"dry_run": True,
|
||||
}
|
||||
|
||||
def _contract_envelope(
|
||||
self,
|
||||
operation: str,
|
||||
result: ContractIngressResult,
|
||||
*,
|
||||
source_ref: str,
|
||||
) -> dict[str, Any]:
|
||||
return self._envelope(
|
||||
operation,
|
||||
subject_kind=result.subject_kind,
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid,
|
||||
diagnostics=result.diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={"value": result.value.to_dict() if hasattr(result.value, "to_dict") else result.value},
|
||||
)
|
||||
|
||||
def _envelope(
|
||||
self,
|
||||
operation: str,
|
||||
*,
|
||||
subject_kind: str,
|
||||
subject_id: str,
|
||||
valid: bool,
|
||||
diagnostics: Iterable[Diagnostic],
|
||||
source_ref: str,
|
||||
data: dict[str, Any],
|
||||
dry_run: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
policy = self.policy_gateway.authorize(
|
||||
action=operation,
|
||||
resource=f"{subject_kind}:{subject_id}",
|
||||
context={"source_ref": source_ref, "dry_run": dry_run},
|
||||
)
|
||||
operation_id = f"op:{stable_digest([operation, subject_kind, subject_id, source_ref, data])}"
|
||||
audit = self.audit_sink.record(
|
||||
audit_event(
|
||||
operation_id=operation_id,
|
||||
operation=operation,
|
||||
subject={"kind": subject_kind, "id": subject_id},
|
||||
policy_decision=policy,
|
||||
dry_run=dry_run,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"schema_version": RUNTIME_ENVELOPE_SCHEMA,
|
||||
"operation_id": operation_id,
|
||||
"operation": operation,
|
||||
"dry_run": dry_run,
|
||||
"valid": valid and policy.allowed,
|
||||
"subject": {"kind": subject_kind, "id": subject_id},
|
||||
"source": {"ref": source_ref},
|
||||
"policy_decision": _policy_to_dict(policy),
|
||||
"audit_receipt": audit,
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics],
|
||||
"data": data,
|
||||
}
|
||||
|
||||
def _apply_action(self, action: LifecycleAction, *, approval_marker: str) -> dict[str, Any]:
|
||||
if action.action == LifecycleActionKind.COMPACT:
|
||||
summary = MemoryNode(
|
||||
action.target_id,
|
||||
"summary",
|
||||
str(action.metadata.get("summary_text") or "Compacted memory summary."),
|
||||
phase=MemoryPhase.STABILIZED,
|
||||
metadata={
|
||||
"source_node_ids": list(action.metadata.get("source_node_ids", ())),
|
||||
"approval_marker": approval_marker,
|
||||
},
|
||||
)
|
||||
self.graph_store.save_node(summary)
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": True}
|
||||
|
||||
if action.to_state is not None:
|
||||
try:
|
||||
node = self.graph_store.get_node(action.target_id)
|
||||
except (KeyError, FileNotFoundError):
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": False, "reason": "missing_node"}
|
||||
self.graph_store.save_node(
|
||||
replace(
|
||||
node,
|
||||
lifecycle=action.to_state,
|
||||
metadata={**dict(node.metadata), "last_lifecycle_action": action.action.value, "approval_marker": approval_marker},
|
||||
)
|
||||
)
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": True}
|
||||
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": False, "reason": "no_state_change"}
|
||||
|
||||
|
||||
def _policy_to_dict(decision: PolicyDecision) -> dict[str, Any]:
|
||||
return decision.to_dict() if hasattr(decision, "to_dict") else to_plain(decision)
|
||||
|
||||
|
||||
def _coerce_action(data: LifecycleAction | dict[str, Any]) -> LifecycleAction:
|
||||
if isinstance(data, LifecycleAction):
|
||||
return data
|
||||
return LifecycleAction(
|
||||
action=LifecycleActionKind(str(data["action"])),
|
||||
target_id=str(data["target_id"]),
|
||||
from_state=LifecycleState(str(data["from_state"])) if data.get("from_state") else None,
|
||||
to_state=LifecycleState(str(data["to_state"])) if data.get("to_state") else None,
|
||||
reason=str(data.get("reason") or ""),
|
||||
requires_review=bool(data.get("requires_review")),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
)
|
||||
|
||||
|
||||
def _action_review_id(action: LifecycleAction) -> str:
|
||||
return f"action:{stable_digest(action.to_dict())}"
|
||||
|
||||
|
||||
def _coerce_review(
|
||||
data: ReviewRecord | dict[str, Any] | None,
|
||||
*,
|
||||
action: LifecycleAction,
|
||||
approval_marker: str,
|
||||
) -> ReviewRecord | None:
|
||||
if isinstance(data, ReviewRecord):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return ReviewRecord.from_mapping(data)
|
||||
if approval_marker:
|
||||
return make_review_record(
|
||||
reviewed_action_id=_action_review_id(action),
|
||||
reviewer="local",
|
||||
approved=True,
|
||||
reason=approval_marker,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _policy_filtered_graph(
|
||||
graph: MemoryGraph,
|
||||
policy_context: dict[str, Any],
|
||||
) -> tuple[MemoryGraph, list[dict[str, Any]], list[Diagnostic]]:
|
||||
allowed_nodes = []
|
||||
denials: list[dict[str, Any]] = []
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for node in graph.nodes:
|
||||
allowed, reasons = evaluate_activation_node(
|
||||
node,
|
||||
required_labels=tuple(policy_context.get("required_labels", ())),
|
||||
denied_labels=tuple(policy_context.get("denied_labels", ())),
|
||||
trust_zone=str(policy_context.get("trust_zone") or ""),
|
||||
secrets_allowed=bool(policy_context.get("secrets_allowed", True)),
|
||||
approved_reauthorizations=tuple(policy_context.get("approved_reauthorizations", ())),
|
||||
require_fresh=bool(policy_context.get("require_fresh", False)),
|
||||
)
|
||||
if allowed:
|
||||
allowed_nodes.append(node)
|
||||
continue
|
||||
denials.append(redacted_node(node, reasons=reasons))
|
||||
diagnostics.append(policy_denial_diagnostic(node, reasons))
|
||||
|
||||
allowed_ids = {node.node_id for node in allowed_nodes}
|
||||
filtered_edges = tuple(edge for edge in graph.edges if edge.source in allowed_ids and edge.target in allowed_ids)
|
||||
return replace(graph, nodes=tuple(allowed_nodes), edges=filtered_edges), denials, diagnostics
|
||||
Reference in New Issue
Block a user