Implement local runtime persistence and policy gates

This commit is contained in:
2026-05-18 18:21:27 +02:00
parent 7f9913c45a
commit 8089a7c8fa
23 changed files with 2263 additions and 42 deletions

View File

@@ -19,12 +19,19 @@ from .models import (
MemoryGraph,
MemoryKind,
MemoryNode,
MemoryPath,
MemoryPathState,
MemoryPhase,
PolicyDecision,
ProfileExecutionPlan,
ProfileIntent,
ReviewDecision,
ReviewRecord,
)
from .paths import abandon_path, branch_path, compact_path, create_path, merge_path, path_event
from .policy import POLICY_OPERATION_POINTS, MemoryOperation, make_review_record
from .planner import plan_profile_execution
from .runtime import PhaseMemoryRuntime
__all__ = [
"ActivationPlan",
@@ -37,11 +44,24 @@ __all__ = [
"MemoryGraph",
"MemoryKind",
"MemoryNode",
"MemoryPath",
"MemoryPathState",
"MemoryPhase",
"PolicyDecision",
"ProfileExecutionPlan",
"ProfileIntent",
"ReviewDecision",
"ReviewRecord",
"PhaseMemoryRuntime",
"POLICY_OPERATION_POINTS",
"MemoryOperation",
"abandon_path",
"branch_path",
"compact_path",
"create_path",
"graph_from_markitect",
"merge_path",
"make_review_record",
"plan_activation",
"plan_compaction",
"plan_phase_transition",
@@ -49,6 +69,7 @@ __all__ = [
"plan_refresh",
"plan_retention",
"profile_from_markitect",
"path_event",
]
__version__ = "0.1.0"

View File

@@ -2,9 +2,13 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .models import MemoryEdge, MemoryEvent, MemoryNode, PolicyDecision, ProfileIntent
from .models import Diagnostic, MemoryEdge, MemoryEvent, MemoryGraph, MemoryNode, MemoryPath, PolicyDecision, ProfileIntent
LOCAL_STORE_SCHEMA = "phase_memory.local_store.v1"
class InMemoryMemoryGraphStore:
@@ -63,6 +67,160 @@ class InMemoryMemoryEventLog:
return events
class FileBackedMemoryGraphStore:
"""Versioned JSON-backed local graph store.
Layout:
- `phase-memory.json`
- `profiles/<profile-id>.json`
- `nodes/<node-id>.json`
- `edges/<edge-id>.json`
- `paths/<path-id>.json`
- `activations/`
- `audit.jsonl`
"""
def __init__(self, root: str | Path) -> None:
self.root = Path(root)
self.profiles_dir = self.root / "profiles"
self.nodes_dir = self.root / "nodes"
self.edges_dir = self.root / "edges"
self.paths_dir = self.root / "paths"
self.activations_dir = self.root / "activations"
self.audit_path = self.root / "audit.jsonl"
self._ensure_layout()
def save_profile(self, profile: ProfileIntent) -> ProfileIntent:
_write_json(self.profiles_dir / f"{_safe_name(profile.profile_id)}.json", profile.to_dict())
return profile
def get_profile(self, profile_id: str) -> ProfileIntent:
return ProfileIntent.from_mapping(_read_json(self.profiles_dir / f"{_safe_name(profile_id)}.json"))
def save_node(self, node: MemoryNode) -> MemoryNode:
_write_json(self.nodes_dir / f"{_safe_name(node.node_id)}.json", node.to_dict())
return node
def get_node(self, node_id: str) -> MemoryNode:
return MemoryNode.from_mapping(_read_json(self.nodes_dir / f"{_safe_name(node_id)}.json"))
def list_nodes(self, *, kind: str | None = None) -> list[MemoryNode]:
nodes = [MemoryNode.from_mapping(_read_json(path)) for path in sorted(self.nodes_dir.glob("*.json"))]
if kind:
nodes = [node for node in nodes if node.kind == kind]
return sorted(nodes, key=lambda node: node.node_id)
def save_edge(self, edge: MemoryEdge) -> MemoryEdge:
_write_json(self.edges_dir / f"{_safe_name(edge.edge_id)}.json", edge.to_dict())
return edge
def list_edges(self, *, source: str | None = None, target: str | None = None) -> list[MemoryEdge]:
edges = [MemoryEdge.from_mapping(_read_json(path)) for path in sorted(self.edges_dir.glob("*.json"))]
if source:
edges = [edge for edge in edges if edge.source == source]
if target:
edges = [edge for edge in edges if edge.target == target]
return sorted(edges, key=lambda edge: edge.edge_id)
def save_path(self, path: MemoryPath) -> MemoryPath:
_write_json(self.paths_dir / f"{_safe_name(path.path_id)}.json", path.to_dict())
return path
def get_path(self, path_id: str) -> MemoryPath:
return MemoryPath.from_mapping(_read_json(self.paths_dir / f"{_safe_name(path_id)}.json"))
def list_paths(self) -> list[MemoryPath]:
return [MemoryPath.from_mapping(_read_json(path)) for path in sorted(self.paths_dir.glob("*.json"))]
def export_graph(self, *, graph_id: str = "local", events: list[MemoryEvent] | None = None) -> MemoryGraph:
return MemoryGraph(
graph_id=graph_id,
nodes=tuple(self.list_nodes()),
edges=tuple(self.list_edges()),
events=tuple(events or ()),
metadata={"store_schema_version": LOCAL_STORE_SCHEMA, "store_path": str(self.root)},
)
def repair_diagnostics(self, *, events: list[MemoryEvent] | None = None) -> tuple[Diagnostic, ...]:
diagnostics: list[Diagnostic] = []
node_ids = {node.node_id for node in self.list_nodes()}
event_ids = {event.event_id for event in events or ()}
for edge in self.list_edges():
if edge.source not in node_ids:
diagnostics.append(Diagnostic("error", "missing_edge_source", "Edge source does not reference a node.", edge.edge_id, {"source": edge.source}))
if edge.target not in node_ids:
diagnostics.append(Diagnostic("error", "missing_edge_target", "Edge target does not reference a node.", edge.edge_id, {"target": edge.target}))
for path in self.list_paths():
for event_id in path.event_ids:
if event_id not in event_ids:
diagnostics.append(Diagnostic("warn", "orphaned_path_event", "Path references an event not present in the event log.", path.path_id, {"event_id": event_id}))
return tuple(diagnostics)
def _ensure_layout(self) -> None:
for directory in (self.root, self.profiles_dir, self.nodes_dir, self.edges_dir, self.paths_dir, self.activations_dir):
directory.mkdir(parents=True, exist_ok=True)
metadata_path = self.root / "phase-memory.json"
if not metadata_path.exists():
_write_json(metadata_path, {"schema_version": LOCAL_STORE_SCHEMA})
class JsonlMemoryEventLog:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.touch(exist_ok=True)
def append(self, event: MemoryEvent) -> MemoryEvent:
if any(existing.event_id == event.event_id for existing in self.list_events()):
raise ValueError(f"Duplicate memory event id: {event.event_id}")
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event.to_dict(), sort_keys=True, separators=(",", ":")) + "\n")
return event
def list_events(self, *, kind: str | None = None) -> list[MemoryEvent]:
events: list[MemoryEvent] = []
for data in self._iter_valid_event_dicts():
event = MemoryEvent.from_mapping(data)
if kind is None or event.kind == kind:
events.append(event)
return sorted(events, key=lambda event: (event.timestamp, event.event_id))
def replay_graph(self, store: FileBackedMemoryGraphStore, *, graph_id: str = "local") -> MemoryGraph:
return store.export_graph(graph_id=graph_id, events=self.list_events())
def diagnostics(self) -> tuple[Diagnostic, ...]:
diagnostics: list[Diagnostic] = []
seen: set[str] = set()
for line_number, raw in self._iter_lines():
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
diagnostics.append(Diagnostic("error", "malformed_event_log_line", "Event log line is not valid JSON.", f"line:{line_number}", {"error": str(exc)}))
continue
schema = data.get("schema_version")
if schema and schema != "markitect.memory.event.v1":
diagnostics.append(Diagnostic("warn", "unknown_event_schema", "Event declares an unknown schema version.", f"line:{line_number}", {"schema_version": schema}))
event_id = str(data.get("id") or data.get("event_id") or "")
if event_id in seen:
diagnostics.append(Diagnostic("error", "duplicate_event_id", "Event log contains a duplicate event id.", f"line:{line_number}", {"event_id": event_id}))
if event_id:
seen.add(event_id)
return tuple(diagnostics)
def _iter_valid_event_dicts(self):
for _, raw in self._iter_lines():
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
yield data
def _iter_lines(self):
for line_number, raw in enumerate(self.path.read_text(encoding="utf-8").splitlines(), start=1):
if raw.strip():
yield line_number, raw
class NoopContextPackageCompiler:
def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]:
return {
@@ -85,3 +243,32 @@ class RecordingAuditSink:
stored = dict(event)
self.events.append(stored)
return {"recorded": True, "index": len(self.events) - 1, "event": stored}
class JsonlAuditSink:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.touch(exist_ok=True)
def record(self, event: dict[str, Any]) -> dict[str, Any]:
stored = dict(event)
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(stored, sort_keys=True, separators=(",", ":")) + "\n")
with self.path.open(encoding="utf-8") as handle:
index = max(sum(1 for _ in handle) - 1, 0)
return {"recorded": True, "index": index, "event": stored}
def _safe_name(identifier: str) -> str:
safe = "".join(char if char.isalnum() or char in ("-", "_", ".") else "_" for char in identifier)
return safe or "anonymous"
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")

196
src/phase_memory/cli.py Normal file
View File

@@ -0,0 +1,196 @@
"""Command line interface for phase-memory."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Sequence
from .adapters import FileBackedMemoryGraphStore, JsonlAuditSink, JsonlMemoryEventLog
from .runtime import PhaseMemoryRuntime
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
runtime = PhaseMemoryRuntime()
envelope = args.func(args, runtime)
if args.format == "summary":
_print_summary(envelope)
else:
print(json.dumps(envelope, indent=2, sort_keys=True))
return 0 if envelope.get("valid") else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="phase-memory")
parser.set_defaults(func=_missing_command, format="json")
subparsers = parser.add_subparsers(dest="command")
profile = subparsers.add_parser("profile", help="Profile operations")
profile_subparsers = profile.add_subparsers(dest="profile_command")
profile_plan = profile_subparsers.add_parser("plan", help="Plan a Markitect memory profile")
profile_plan.add_argument("profile", type=Path)
_add_format(profile_plan)
profile_plan.set_defaults(func=_profile_plan)
graph = subparsers.add_parser("graph", help="Graph operations")
graph_subparsers = graph.add_subparsers(dest="graph_command")
lifecycle = graph_subparsers.add_parser("lifecycle", help="Plan graph lifecycle actions")
lifecycle.add_argument("graph", type=Path)
lifecycle.add_argument("--stale-after-days", type=int)
lifecycle.add_argument("--delete-after-days", type=int)
lifecycle.add_argument("--refresh-digest", action="append", default=[], metavar="NODE_ID=DIGEST")
lifecycle.add_argument("--compact-node", action="append", default=[])
_add_format(lifecycle)
lifecycle.set_defaults(func=_graph_lifecycle)
activate = graph_subparsers.add_parser("activate", help="Plan graph activation selection")
activate.add_argument("graph", type=Path)
activate.add_argument("--max-items", type=int, required=True)
activate.add_argument("--max-tokens", type=int, required=True)
activate.add_argument("--profile-id")
activate.add_argument("--priority-node", action="append", default=[])
activate.add_argument("--no-events", action="store_true")
_add_format(activate)
activate.set_defaults(func=_graph_activate)
store = subparsers.add_parser("store", help="Local file-backed store operations")
store_subparsers = store.add_subparsers(dest="store_command")
store_import = store_subparsers.add_parser("import", help="Import profile and graph fixtures into a local store")
store_import.add_argument("--store", type=Path, required=True)
store_import.add_argument("--profile", type=Path)
store_import.add_argument("--graph", type=Path)
_add_format(store_import)
store_import.set_defaults(func=_store_import)
store_export = store_subparsers.add_parser("export", help="Export a local store as a graph envelope")
store_export.add_argument("--store", type=Path, required=True)
store_export.add_argument("--graph-id", default="local")
_add_format(store_export)
store_export.set_defaults(func=_store_export)
store_repair = store_subparsers.add_parser("repair", help="Report local store repair diagnostics")
store_repair.add_argument("--store", type=Path, required=True)
_add_format(store_repair)
store_repair.set_defaults(func=_store_repair)
return parser
def _add_format(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--format", choices=("json", "summary"), default="json")
def _profile_plan(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
return runtime.plan_profile(_read_json(args.profile), source_ref=str(args.profile))
def _graph_lifecycle(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
return runtime.plan_lifecycle(
_read_json(args.graph),
source_ref=str(args.graph),
stale_after_days=args.stale_after_days,
delete_after_days=args.delete_after_days,
refresh_digests=_parse_digest_args(args.refresh_digest),
compact_node_ids=tuple(args.compact_node),
)
def _graph_activate(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
return runtime.plan_activation(
_read_json(args.graph),
source_ref=str(args.graph),
max_items=args.max_items,
max_tokens=args.max_tokens,
profile_id=args.profile_id,
priority_node_ids=tuple(args.priority_node),
include_events=not args.no_events,
)
def _store_import(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
runtime = _runtime_for_store(args.store)
results = []
if args.profile:
results.append(runtime.import_profile(_read_json(args.profile), source_ref=str(args.profile)))
if args.graph:
results.append(runtime.import_graph(_read_json(args.graph), source_ref=str(args.graph)))
return {
"schema_version": "phase_memory.store.import.v1",
"operation": "store.import",
"valid": all(result.get("valid") for result in results),
"dry_run": False,
"store": str(args.store),
"results": results,
}
def _store_export(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
return _runtime_for_store(args.store).export_graph(graph_id=args.graph_id, source_ref=str(args.store))
def _store_repair(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
return _runtime_for_store(args.store).repair_diagnostics(source_ref=str(args.store))
def _missing_command(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
raise SystemExit("Missing command. Use --help for usage.")
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _parse_digest_args(items: list[str]) -> dict[str, str]:
digests: dict[str, str] = {}
for item in items:
if "=" not in item:
raise SystemExit(f"Expected --refresh-digest NODE_ID=DIGEST, got: {item}")
node_id, digest = item.split("=", 1)
digests[node_id] = digest
return digests
def _runtime_for_store(path: Path) -> PhaseMemoryRuntime:
store = FileBackedMemoryGraphStore(path)
return PhaseMemoryRuntime(
graph_store=store,
event_log=JsonlMemoryEventLog(path / "events.jsonl"),
audit_sink=JsonlAuditSink(path / "audit.jsonl"),
)
def _print_summary(envelope: dict[str, Any]) -> None:
subject = envelope.get("subject", {})
print(f"{envelope.get('operation')} {subject.get('kind')}:{subject.get('id')}")
print(f"valid={str(envelope.get('valid')).lower()} dry_run={str(envelope.get('dry_run')).lower()}")
diagnostics = envelope.get("diagnostics", [])
if diagnostics:
print(f"diagnostics={len(diagnostics)}")
data = envelope.get("data", {})
if "plan" in data and data["plan"]:
plan = data["plan"]
print(f"ready={str(plan.get('ready')).lower()}")
print(f"capabilities={','.join(plan.get('capabilities', []))}")
if "dry_run_actions" in data:
print(f"actions={len(data.get('dry_run_actions', []))}")
if envelope.get("operation") == "store.import":
print(f"results={len(envelope.get('results', []))}")
if data.get("graph"):
graph = data["graph"]
print(f"nodes={len(graph.get('nodes', []))}")
print(f"edges={len(graph.get('edges', []))}")
print(f"events={len(graph.get('events', []))}")
activation = data.get("activation_plan")
if activation:
print(f"selected_nodes={len(activation.get('selected_node_ids', []))}")
print(f"omitted={len(activation.get('omitted', []))}")
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -53,6 +53,18 @@ class LifecycleActionKind(str, Enum):
NO_OP = "no_op"
class MemoryPathState(str, Enum):
ACTIVE = "active"
MERGED = "merged"
ABANDONED = "abandoned"
COMPACTED = "compacted"
class ReviewDecision(str, Enum):
APPROVED = "approved"
REJECTED = "rejected"
@dataclass(frozen=True)
class Diagnostic:
severity: str
@@ -279,6 +291,51 @@ class MemoryEvent:
)
@dataclass(frozen=True)
class MemoryPath:
path_id: str
parent_path_id: str = ""
event_ids: tuple[str, ...] = ()
state: MemoryPathState = MemoryPathState.ACTIVE
merged_into: str = ""
abandoned_reason: str = ""
compacted_summary_id: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=utc_now_iso)
updated_at: str = field(default_factory=utc_now_iso)
@classmethod
def from_mapping(cls, data: dict[str, Any]) -> "MemoryPath":
return cls(
path_id=str(data.get("id") or data.get("path_id") or ""),
parent_path_id=str(data.get("parent_path_id") or ""),
event_ids=tuple(str(item) for item in data.get("event_ids", ())),
state=MemoryPathState(str(data.get("state") or MemoryPathState.ACTIVE.value)),
merged_into=str(data.get("merged_into") or ""),
abandoned_reason=str(data.get("abandoned_reason") or ""),
compacted_summary_id=str(data.get("compacted_summary_id") or ""),
metadata=dict(data.get("metadata") or {}),
created_at=str(data.get("created_at") or utc_now_iso()),
updated_at=str(data.get("updated_at") or utc_now_iso()),
)
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"id": self.path_id,
"parent_path_id": self.parent_path_id,
"event_ids": list(self.event_ids),
"state": self.state,
"merged_into": self.merged_into,
"abandoned_reason": self.abandoned_reason,
"compacted_summary_id": self.compacted_summary_id,
"metadata": self.metadata,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
)
@dataclass(frozen=True)
class MemoryGraph:
graph_id: str
@@ -326,6 +383,52 @@ class PolicyDecision:
)
@dataclass(frozen=True)
class ReviewRecord:
review_id: str
reviewed_action_id: str
reviewer: str
decision: ReviewDecision
reason: str = ""
obligations: tuple[str, ...] = ()
source_digests: dict[str, str] = field(default_factory=dict)
timestamp: str = field(default_factory=utc_now_iso)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_mapping(cls, data: dict[str, Any]) -> "ReviewRecord":
return cls(
review_id=str(data.get("id") or data.get("review_id") or ""),
reviewed_action_id=str(data.get("reviewed_action_id") or data.get("action_id") or ""),
reviewer=str(data.get("reviewer") or data.get("reviewer_id") or "local"),
decision=ReviewDecision(str(data.get("decision") or ReviewDecision.REJECTED.value)),
reason=str(data.get("reason") or ""),
obligations=tuple(str(item) for item in data.get("obligations", ())),
source_digests={str(key): str(value) for key, value in dict(data.get("source_digests") or {}).items()},
timestamp=str(data.get("timestamp") or utc_now_iso()),
metadata=dict(data.get("metadata") or {}),
)
@property
def approved(self) -> bool:
return self.decision == ReviewDecision.APPROVED
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"id": self.review_id,
"reviewed_action_id": self.reviewed_action_id,
"reviewer": self.reviewer,
"decision": self.decision,
"reason": self.reason,
"obligations": list(self.obligations),
"source_digests": self.source_digests,
"timestamp": self.timestamp,
"metadata": self.metadata,
}
)
@dataclass(frozen=True)
class LifecycleAction:
action: LifecycleActionKind

47
src/phase_memory/paths.py Normal file
View File

@@ -0,0 +1,47 @@
"""Structured conversational path helpers."""
from __future__ import annotations
from dataclasses import replace
from .models import MemoryEvent, MemoryPath, MemoryPathState
from .utils import stable_digest, utc_now_iso
def create_path(path_id: str, *, event_ids: tuple[str, ...] = (), metadata: dict | None = None) -> MemoryPath:
return MemoryPath(path_id=path_id, event_ids=event_ids, metadata=dict(metadata or {}))
def branch_path(parent: MemoryPath, path_id: str, *, event_ids: tuple[str, ...] = ()) -> MemoryPath:
return MemoryPath(
path_id=path_id,
parent_path_id=parent.path_id,
event_ids=event_ids,
metadata={"branched_from": parent.path_id},
)
def merge_path(path: MemoryPath, target_path_id: str) -> MemoryPath:
return replace(path, state=MemoryPathState.MERGED, merged_into=target_path_id, updated_at=utc_now_iso())
def abandon_path(path: MemoryPath, reason: str) -> MemoryPath:
return replace(path, state=MemoryPathState.ABANDONED, abandoned_reason=reason, updated_at=utc_now_iso())
def compact_path(path: MemoryPath, summary_node_id: str) -> MemoryPath:
return replace(path, state=MemoryPathState.COMPACTED, compacted_summary_id=summary_node_id, updated_at=utc_now_iso())
def path_event(path: MemoryPath, kind: str, *, metadata: dict | None = None) -> MemoryEvent:
event_id = f"path-event:{stable_digest([path.path_id, kind, path.event_ids, metadata or {}])}"
return MemoryEvent(
event_id=event_id,
kind=kind,
metadata={
"path_id": path.path_id,
"parent_path_id": path.parent_path_id,
"path_state": path.state.value,
**dict(metadata or {}),
},
)

145
src/phase_memory/policy.py Normal file
View File

@@ -0,0 +1,145 @@
"""Policy, review, redaction, and audit helpers."""
from __future__ import annotations
from enum import Enum
from typing import Any
from .models import Diagnostic, MemoryNode, PolicyDecision, ReviewDecision, ReviewRecord
from .utils import stable_digest, utc_now_iso
AUDIT_EVENT_SCHEMA = "phase_memory.audit.event.v1"
REDACTED = "[REDACTED]"
class MemoryOperation(str, Enum):
PROFILE_IMPORT = "profile.import"
GRAPH_IMPORT = "graph.import"
NODE_READ = "node.read"
EVENT_READ = "event.read"
PROFILE_PLAN = "profile.plan"
LIFECYCLE_PLAN = "graph.lifecycle.plan"
ACTIVATION_PLAN = "graph.activation.plan"
PACKAGE_COMPILE = "package.compile"
LIFECYCLE_APPLY = "lifecycle.apply"
STABILIZATION = "memory.stabilize"
COMPACTION = "memory.compact"
REFRESH = "memory.refresh"
DELETE_REQUEST = "memory.delete_request"
ARCHIVE = "memory.archive"
GRAPH_EXPORT = "graph.export"
STORE_REPAIR = "store.repair.diagnostics"
POLICY_OPERATION_POINTS = tuple(operation.value for operation in MemoryOperation)
def make_review_record(
*,
reviewed_action_id: str,
reviewer: str,
approved: bool,
reason: str = "",
obligations: tuple[str, ...] = (),
source_digests: dict[str, str] | None = None,
) -> ReviewRecord:
decision = ReviewDecision.APPROVED if approved else ReviewDecision.REJECTED
review_id = f"review:{stable_digest([reviewed_action_id, reviewer, decision.value, reason, source_digests or {}])}"
return ReviewRecord(
review_id=review_id,
reviewed_action_id=reviewed_action_id,
reviewer=reviewer,
decision=decision,
reason=reason,
obligations=obligations,
source_digests=dict(source_digests or {}),
)
def audit_event(
*,
operation_id: str,
operation: str,
subject: dict[str, str],
policy_decision: PolicyDecision,
dry_run: bool,
source_ref: str,
actor: str = "local",
planned_action_id: str = "",
profile_id: str = "",
graph_id: str = "",
) -> dict[str, Any]:
return {
"schema_version": AUDIT_EVENT_SCHEMA,
"operation_id": operation_id,
"operation": operation,
"subject": dict(subject),
"allowed": policy_decision.allowed,
"profile_id": profile_id,
"graph_id": graph_id,
"policy_decision": policy_decision.to_dict(),
"dry_run": dry_run,
"planned_action_id": planned_action_id,
"actor": actor,
"timestamp": utc_now_iso(),
"source": {"ref": source_ref},
}
def evaluate_activation_node(
node: MemoryNode,
*,
required_labels: tuple[str, ...] = (),
denied_labels: tuple[str, ...] = (),
trust_zone: str = "",
secrets_allowed: bool = True,
approved_reauthorizations: tuple[str, ...] = (),
require_fresh: bool = False,
) -> tuple[bool, tuple[str, ...]]:
labels = set(_list_value(node.policy.get("labels")) + _list_value(node.metadata.get("labels")))
reasons: list[str] = []
for label in required_labels:
if label not in labels:
reasons.append(f"missing_label:{label}")
for label in denied_labels:
if label in labels:
reasons.append(f"denied_label:{label}")
node_trust_zone = str(node.policy.get("trust_zone") or node.metadata.get("trust_zone") or "")
if trust_zone and node_trust_zone and node_trust_zone != trust_zone:
reasons.append(f"trust_zone:{node_trust_zone}")
if not secrets_allowed and bool(node.policy.get("secret") or node.metadata.get("secret")):
reasons.append("secret_denied")
reauthorization = str(node.policy.get("reauthorization") or "")
if reauthorization and reauthorization not in approved_reauthorizations:
reasons.append(f"reauthorization:{reauthorization}")
if require_fresh and str(node.freshness.get("status") or "").lower() == "stale":
reasons.append("freshness:stale")
return not reasons, tuple(reasons)
def redacted_node(node: MemoryNode, *, reasons: tuple[str, ...]) -> dict[str, Any]:
return {
"id": node.node_id,
"kind": node.kind,
"text": REDACTED,
"policy": REDACTED,
"reasons": list(reasons),
}
def policy_denial_diagnostic(node: MemoryNode, reasons: tuple[str, ...]) -> Diagnostic:
return Diagnostic(
"warn",
"activation_policy_denied",
"Memory node was omitted from activation by policy.",
node.node_id,
{"reasons": list(reasons), "redacted": redacted_node(node, reasons=reasons)},
)
def _list_value(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [value]
return [str(item) for item in value]

471
src/phase_memory/runtime.py Normal file
View 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