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

@@ -35,6 +35,43 @@ python3 -m pytest
The default test suite uses only deterministic local fixtures.
From a checkout, run the CLI through the local source tree:
```bash
PYTHONPATH=src python3 -m phase_memory.cli profile plan tests/fixtures/memory-profile.json
PYTHONPATH=src python3 -m phase_memory.cli graph lifecycle tests/fixtures/memory-graph.json --stale-after-days 7 --delete-after-days 30
PYTHONPATH=src python3 -m phase_memory.cli graph activate tests/fixtures/memory-graph.json --max-items 3 --max-tokens 60
PYTHONPATH=src python3 -m phase_memory.cli store import --store .phase-memory-local --profile tests/fixtures/memory-profile.json --graph tests/fixtures/memory-graph.json
```
When installed, the package exposes the same commands as `phase-memory`.
Commands emit JSON runtime envelopes by default and accept `--format summary`
for a concise human-readable view. All current commands are dry-run planning
operations; they do not mutate durable memory stores.
## Local Runtime
`PhaseMemoryRuntime` is the dependency-light application facade for local
integrations. It coordinates contract ingress, profile planning, lifecycle
planning, activation planning, the context-package compiler port, policy
checks, and audit recording.
Example:
```python
import json
from phase_memory import PhaseMemoryRuntime
runtime = PhaseMemoryRuntime()
profile = json.load(open("tests/fixtures/memory-profile.json", encoding="utf-8"))
envelope = runtime.plan_profile(profile, source_ref="tests/fixtures/memory-profile.json")
```
Runtime outputs use stable JSON-serializable envelopes with operation ids,
diagnostics, policy decisions, audit receipts, dry-run flags, and source
references. Activation planning also includes a Markitect-compatible selection
and a package compilation request for the `ContextPackageCompiler` boundary.
## Package Map
- `phase_memory.models`: domain records, phases, lifecycle states, diagnostics,
@@ -45,6 +82,10 @@ The default test suite uses only deterministic local fixtures.
- `phase_memory.activation`: Markitect-compatible activation selection planning.
- `phase_memory.ports`: runtime port protocols.
- `phase_memory.adapters`: deterministic in-memory test adapters.
- `phase_memory.runtime`: local runtime facade and stable operation envelopes.
- `phase_memory.cli`: dependency-light command-line interface.
See [docs/architecture.md](docs/architecture.md) for the first architecture
sketch and [SCOPE.md](SCOPE.md) for repository boundaries.
sketch, [docs/local-persistence.md](docs/local-persistence.md) for the local
file-backed adapter, [docs/policy-audit.md](docs/policy-audit.md) for local
policy and review gates, and [SCOPE.md](SCOPE.md) for repository boundaries.

View File

@@ -146,6 +146,49 @@ The slice should emit plans first, not mutate durable memory by surprise.
Durable writes, external adapters, live LLM extraction, vector retrieval, and
service deployment can follow once the plan model is stable.
## Local Runtime Facade
The second implementation slice adds a local facade, `PhaseMemoryRuntime`, over
the deterministic core. The facade is not a service runner. It is the small
application surface that adjacent tools can call before a service deployment
exists.
Runtime operations currently include:
- profile import
- graph import
- profile execution planning
- graph lifecycle planning
- graph activation planning
- package compilation handoff through `ContextPackageCompiler`
Each operation returns a JSON-serializable envelope with:
- `schema_version`
- `operation_id`
- `operation`
- `dry_run`
- `valid`
- `subject`
- `source`
- `policy_decision`
- `audit_receipt`
- `diagnostics`
- `data`
The local CLI exposes the same facade for fixtures and developer workflows:
```bash
phase-memory profile plan profile.json
phase-memory graph lifecycle graph.json --stale-after-days 7 --delete-after-days 30
phase-memory graph activate graph.json --max-items 3 --max-tokens 60
```
The default implementation uses in-memory stores, an allow-all local policy
gateway, a recording audit sink, and a noop context-package compiler. These are
test and integration adapters, not a claim that durable persistence, policy, or
package internals belong in this repository.
## Open Questions
- Should `phase-memory` depend directly on `markitect-tool` for validation, or

91
docs/local-persistence.md Normal file
View File

@@ -0,0 +1,91 @@
# Local Persistence
`phase-memory` can run against a versioned local file workspace. This is a
developer and integration adapter, not a production graph database.
## Layout
```text
memory-store/
phase-memory.json
profiles/
<profile-id>.json
nodes/
<node-id>.json
edges/
<edge-id>.json
paths/
<path-id>.json
activations/
events.jsonl
audit.jsonl
```
The root `phase-memory.json` declares:
```json
{
"schema_version": "phase_memory.local_store.v1"
}
```
Profiles, nodes, edges, and paths are stored as deterministic JSON files.
Events and audit records are append-only JSONL files. The current local runtime
does not compact, delete, or rewrite append-only logs.
## CLI
Import local fixtures:
```bash
PYTHONPATH=src python3 -m phase_memory.cli store import \
--store .phase-memory-local \
--profile tests/fixtures/memory-profile.json \
--graph tests/fixtures/memory-graph.json
```
Export a Markitect-compatible graph envelope:
```bash
PYTHONPATH=src python3 -m phase_memory.cli store export \
--store .phase-memory-local \
--graph-id local-dev
```
Inspect repair diagnostics:
```bash
PYTHONPATH=src python3 -m phase_memory.cli store repair \
--store .phase-memory-local
```
Repair diagnostics report malformed JSONL event lines, unknown event schema
versions, missing edge endpoints, and path records that reference events not
present in the event log.
## Paths
Conversational paths are structured records, not transcript blobs. A path can
record:
- `path_id`
- `parent_path_id`
- ordered `event_ids`
- active, merged, abandoned, or compacted state
- merge target
- abandoned reason
- compacted summary id
Helper functions in `phase_memory.paths` create, branch, merge, abandon, and
compact paths while also producing structured path events for the fluid memory
event log.
## Review-Gated Apply
Lifecycle planning remains dry-run by default. The runtime exposes an optional
`apply_lifecycle_actions` operation for local stores. Actions marked
`requires_review` are denied unless the caller provides an explicit
`approval_marker`.
This keeps the local adapter useful for development while preserving the
project rule that durable memory changes must be inspectable and deliberate.

113
docs/policy-audit.md Normal file
View File

@@ -0,0 +1,113 @@
# Policy And Audit
`phase-memory` keeps policy enforcement adapter-based. The local runtime
defines deterministic operation points, review records, audit envelopes, and
redaction behavior without becoming an identity or authorization platform.
## Operation Points
Canonical operation names live in `phase_memory.policy.MemoryOperation`.
Current operation points include:
- `profile.import`
- `graph.import`
- `node.read`
- `event.read`
- `profile.plan`
- `graph.lifecycle.plan`
- `graph.activation.plan`
- `package.compile`
- `lifecycle.apply`
- `memory.stabilize`
- `memory.compact`
- `memory.refresh`
- `memory.delete_request`
- `memory.archive`
- `graph.export`
- `store.repair.diagnostics`
Runtime operations call the configured `PolicyGateway` before emitting an
envelope. The default local adapter is allow-all and exists only for
dependency-light tests and local development.
## Review Records
Review-required lifecycle actions fail closed unless a caller provides an
approved review record or the legacy local `approval_marker` shorthand.
Review records capture:
- review id
- reviewed action id
- reviewer
- approval or rejection
- timestamp
- reason
- obligations
- source digests
The reviewed action id is deterministic:
```text
action:<digest-of-lifecycle-action>
```
This lets the runtime reject a review record that was issued for a different
planned action.
## Activation Policy
Activation planning can receive a local policy context:
```python
runtime.plan_activation(
graph,
max_items=4,
max_tokens=80,
policy_context={
"required_labels": ["project-local"],
"denied_labels": ["restricted"],
"trust_zone": "local",
"secrets_allowed": False,
"approved_reauthorizations": [],
},
)
```
Nodes denied by policy are omitted before selection. The runtime returns
diagnostics with code `activation_policy_denied` and a deterministic redacted
record where policy-sensitive fields and text are replaced with `[REDACTED]`.
## Audit Envelope
Audit events use schema `phase_memory.audit.event.v1` and include:
- operation id
- operation kind
- subject id
- profile id
- graph id
- policy decision
- dry-run flag
- planned action id
- actor label
- timestamp
- source reference
The local audit sinks record these events either in memory or as append-only
JSONL. External audit systems should implement the `AuditSink` port.
## Boundaries
This repository does not own:
- user identity
- enterprise policy languages
- remote policy decision points
- long-term audit retention
- legal hold or compliance workflows
Those belong behind adapters. `phase-memory` owns the memory-native points
where policy, review, redaction, and audit decisions must be requested and
explained.

View File

@@ -14,6 +14,9 @@ authors = [
]
dependencies = []
[project.scripts]
phase-memory = "phase_memory.cli:main"
[tool.setuptools.packages.find]
where = ["src"]

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

View File

@@ -0,0 +1,29 @@
{
"data": {
"omitted": [
"artifact.profile:max_tokens",
"event.restart:max_tokens",
"risk.durable-write:max_tokens"
],
"package_request_id": "package-request:854b8a9e0f9a",
"plan_id": "activation:6e0ba40234cd",
"selected_event_ids": [
"event.activation"
],
"selected_node_ids": [
"decision.boundary"
],
"token_estimate": 9
},
"diagnostic_codes": [
"activation_omitted_items"
],
"operation": "graph.activation.plan",
"operation_id": "op:826d3b06fa5b",
"schema_version": "phase_memory.runtime.envelope.v1",
"subject": {
"id": "phase-memory-fixture-graph",
"kind": "memory_graph"
},
"valid": true
}

View File

@@ -0,0 +1,33 @@
{
"data": {
"capabilities": [
"activation.plan",
"compaction.plan",
"policy.gate",
"profile.inspect",
"profile.plan",
"refresh.plan",
"retention.plan"
],
"policy_gates": [
"label:project-local",
"durable_writes:review-gated",
"secrets:denied"
],
"ready": true,
"required_adapters": [
"local-event-log",
"local-graph-store",
"markitect-context-package"
]
},
"diagnostic_codes": [],
"operation": "profile.plan",
"operation_id": "op:0277ca92fd86",
"schema_version": "phase_memory.runtime.envelope.v1",
"subject": {
"id": "phase-memory-fixture-profile",
"kind": "memory_profile"
},
"valid": true
}

112
tests/test_cli.py Normal file
View File

@@ -0,0 +1,112 @@
import json
import tomllib
from pathlib import Path
from phase_memory.cli import main
FIXTURES = Path(__file__).parent / "fixtures"
ROOT = Path(__file__).resolve().parents[1]
def test_pyproject_exposes_phase_memory_console_script() -> None:
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
assert pyproject["project"]["scripts"]["phase-memory"] == "phase_memory.cli:main"
def test_cli_profile_plan_emits_json(capsys) -> None:
code = main(["profile", "plan", str(FIXTURES / "memory-profile.json")])
output = json.loads(capsys.readouterr().out)
assert code == 0
assert output["operation"] == "profile.plan"
assert output["data"]["plan"]["ready"] is True
def test_cli_graph_lifecycle_emits_dry_run_actions(capsys) -> None:
code = main(
[
"graph",
"lifecycle",
str(FIXTURES / "memory-graph.json"),
"--stale-after-days",
"7",
"--delete-after-days",
"30",
"--refresh-digest",
"event.restart=new",
"--compact-node",
"event.restart",
]
)
output = json.loads(capsys.readouterr().out)
assert code == 0
assert output["operation"] == "graph.lifecycle.plan"
assert [action["action"] for action in output["data"]["dry_run_actions"]][:2] == ["mark_stale", "refresh"]
def test_cli_graph_activate_emits_selection(capsys) -> None:
code = main(
[
"graph",
"activate",
str(FIXTURES / "memory-graph.json"),
"--max-items",
"2",
"--max-tokens",
"18",
"--profile-id",
"phase-memory-fixture-profile",
"--priority-node",
"decision.boundary",
]
)
output = json.loads(capsys.readouterr().out)
assert code == 0
assert output["operation"] == "graph.activation.plan"
assert output["data"]["activation_plan"]["selection"]["profile"] == "phase-memory-fixture-profile"
def test_cli_summary_format_is_concise(capsys) -> None:
code = main(["profile", "plan", str(FIXTURES / "memory-profile.json"), "--format", "summary"])
output = capsys.readouterr().out
assert code == 0
assert "profile.plan memory_profile:phase-memory-fixture-profile" in output
assert "ready=true" in output
def test_cli_store_import_export_and_repair(tmp_path, capsys) -> None:
store = tmp_path / "memory-store"
import_code = main(
[
"store",
"import",
"--store",
str(store),
"--profile",
str(FIXTURES / "memory-profile.json"),
"--graph",
str(FIXTURES / "memory-graph.json"),
]
)
imported = json.loads(capsys.readouterr().out)
export_code = main(["store", "export", "--store", str(store), "--graph-id", "cli-export"])
exported = json.loads(capsys.readouterr().out)
repair_code = main(["store", "repair", "--store", str(store)])
repair = json.loads(capsys.readouterr().out)
assert import_code == 0
assert imported["operation"] == "store.import"
assert (store / "phase-memory.json").exists()
assert export_code == 0
assert exported["data"]["graph"]["id"] == "cli-export"
assert len(exported["data"]["graph"]["nodes"]) == 4
assert repair_code == 0
assert repair["data"]["diagnostic_count"] == 0

View File

@@ -0,0 +1,122 @@
import json
from pathlib import Path
from phase_memory.adapters import FileBackedMemoryGraphStore, JsonlAuditSink, JsonlMemoryEventLog
from phase_memory.lifecycle import plan_compaction, plan_retention
from phase_memory.models import LifecycleAction, LifecycleActionKind, LifecycleState, MemoryEdge, MemoryEvent, MemoryNode
from phase_memory.paths import abandon_path, branch_path, create_path, merge_path, path_event
from phase_memory.runtime import PhaseMemoryRuntime
FIXTURES = Path(__file__).parent / "fixtures"
def _load(name: str):
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def test_file_backed_store_round_trips_and_exports_graph(tmp_path) -> None:
store = FileBackedMemoryGraphStore(tmp_path)
event_log = JsonlMemoryEventLog(tmp_path / "events.jsonl")
runtime = PhaseMemoryRuntime(graph_store=store, event_log=event_log, audit_sink=JsonlAuditSink(tmp_path / "audit.jsonl"))
runtime.import_profile(_load("memory-profile.json"), source_ref="profile")
runtime.import_graph(_load("memory-graph.json"), source_ref="graph")
assert store.get_profile("phase-memory-fixture-profile").profile_id == "phase-memory-fixture-profile"
assert store.get_node("decision.boundary").kind == "decision"
assert store.list_edges(source="decision.boundary")[0].target == "artifact.profile"
exported = runtime.export_graph(graph_id="exported")["data"]["graph"]
assert exported["schema_version"] == "markitect.memory.graph.v1"
assert exported["id"] == "exported"
assert len(exported["nodes"]) == 4
assert len(exported["edges"]) == 2
assert len(exported["events"]) == 1
def test_jsonl_event_log_detects_duplicates_and_corruption(tmp_path) -> None:
log = JsonlMemoryEventLog(tmp_path / "events.jsonl")
event = MemoryEvent("event.a", "recorded", timestamp="2026-05-18T00:00:00+00:00")
log.append(event)
try:
log.append(event)
except ValueError as exc:
assert "Duplicate memory event id" in str(exc)
else:
raise AssertionError("duplicate event id should fail")
with (tmp_path / "events.jsonl").open("a", encoding="utf-8") as handle:
handle.write("{not-json}\n")
handle.write(json.dumps({"schema_version": "unknown.event.v9", "id": "event.b", "kind": "recorded"}) + "\n")
diagnostics = log.diagnostics()
assert [diagnostic.code for diagnostic in diagnostics] == ["malformed_event_log_line", "unknown_event_schema"]
assert log.list_events(kind="recorded")[0].event_id == "event.a"
def test_memory_paths_model_branch_merge_and_abandon_as_events(tmp_path) -> None:
store = FileBackedMemoryGraphStore(tmp_path)
root = create_path("path.root", event_ids=("event.root",))
branch = branch_path(root, "path.branch", event_ids=("event.branch",))
merged = merge_path(branch, "path.root")
abandoned = abandon_path(branch, "superseded by main path")
store.save_path(root)
store.save_path(merged)
assert store.get_path("path.branch").merged_into == "path.root"
assert path_event(merged, "path.merged").metadata["path_state"] == "merged"
assert abandoned.abandoned_reason == "superseded by main path"
def test_repair_diagnostics_report_missing_edges_and_orphaned_path_events(tmp_path) -> None:
store = FileBackedMemoryGraphStore(tmp_path)
log = JsonlMemoryEventLog(tmp_path / "events.jsonl")
runtime = PhaseMemoryRuntime(graph_store=store, event_log=log)
store.save_node(MemoryNode("node.a", "decision"))
store.save_edge(MemoryEdge("edge.bad", "depends_on", "node.a", "missing"))
store.save_path(create_path("path.root", event_ids=("missing.event",)))
envelope = runtime.repair_diagnostics(source_ref=str(tmp_path))
assert envelope["valid"] is False
assert [diagnostic["code"] for diagnostic in envelope["diagnostics"]] == ["missing_edge_target", "orphaned_path_event"]
def test_lifecycle_apply_requires_approval_for_reviewable_actions(tmp_path) -> None:
store = FileBackedMemoryGraphStore(tmp_path)
runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl"))
node = store.save_node(MemoryNode("node.a", "episode", "Trace text"))
compact = plan_compaction([node])
denied = runtime.apply_lifecycle_actions([compact])
assert denied["valid"] is False
assert denied["data"]["denied"][0]["reason"] == "review_required"
assert [node.node_id for node in store.list_nodes()] == ["node.a"]
applied = runtime.apply_lifecycle_actions([compact], approval_marker="review:local")
assert applied["valid"] is True
assert store.get_node(compact.target_id).kind == "summary"
def test_lifecycle_apply_can_mark_non_review_actions(tmp_path) -> None:
store = FileBackedMemoryGraphStore(tmp_path)
runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl"))
store.save_node(MemoryNode("stale", "episode", freshness={"updated_at": "2026-05-01T00:00:00+00:00"}))
action = LifecycleAction(
LifecycleActionKind.MARK_STALE,
"stale",
from_state=LifecycleState.ACTIVE,
to_state=LifecycleState.STALE,
)
envelope = runtime.apply_lifecycle_actions([action])
assert envelope["valid"] is True
assert store.get_node("stale").lifecycle == LifecycleState.STALE

View File

@@ -0,0 +1,105 @@
import json
from pathlib import Path
from phase_memory.adapters import FileBackedMemoryGraphStore, JsonlMemoryEventLog
from phase_memory.lifecycle import plan_compaction
from phase_memory.models import MemoryNode, ReviewDecision
from phase_memory.policy import AUDIT_EVENT_SCHEMA, POLICY_OPERATION_POINTS, REDACTED, make_review_record
from phase_memory.runtime import PhaseMemoryRuntime
from phase_memory.utils import stable_digest
FIXTURES = Path(__file__).parent / "fixtures"
def _load(name: str):
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def test_policy_operation_points_cover_runtime_boundaries() -> None:
assert "profile.import" in POLICY_OPERATION_POINTS
assert "graph.activation.plan" in POLICY_OPERATION_POINTS
assert "lifecycle.apply" in POLICY_OPERATION_POINTS
assert "graph.export" in POLICY_OPERATION_POINTS
def test_runtime_audit_events_use_stable_schema() -> None:
runtime = PhaseMemoryRuntime()
envelope = runtime.plan_profile(_load("memory-profile.json"), source_ref="profile")
event = envelope["audit_receipt"]["event"]
assert event["schema_version"] == AUDIT_EVENT_SCHEMA
assert event["operation"] == "profile.plan"
assert event["allowed"] is True
assert event["policy_decision"]["allowed"] is True
assert event["source"]["ref"] == "profile"
def test_review_record_approves_review_required_lifecycle_action(tmp_path) -> None:
store = FileBackedMemoryGraphStore(tmp_path)
runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl"))
node = store.save_node(MemoryNode("node.a", "episode", "Trace text"))
action = plan_compaction([node])
review = make_review_record(
reviewed_action_id=f"action:{stable_digest(action.to_dict())}",
reviewer="reviewer.local",
approved=True,
reason="reviewed compacted summary",
obligations=("retain_sources",),
source_digests={"node.a": "digest"},
)
envelope = runtime.apply_lifecycle_actions([action], review_record=review)
assert review.decision == ReviewDecision.APPROVED
assert envelope["valid"] is True
assert envelope["data"]["review_record"]["id"] == review.review_id
assert store.get_node(action.target_id).metadata["approval_marker"] == review.review_id
def test_rejected_or_mismatched_review_record_denies_apply(tmp_path) -> None:
store = FileBackedMemoryGraphStore(tmp_path)
runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl"))
node = store.save_node(MemoryNode("node.a", "episode", "Trace text"))
action = plan_compaction([node])
review = make_review_record(
reviewed_action_id="action:other",
reviewer="reviewer.local",
approved=True,
reason="wrong action",
)
envelope = runtime.apply_lifecycle_actions([action], review_record=review)
assert envelope["valid"] is False
assert envelope["data"]["denied"][0]["reason"] == "review_required"
def test_activation_policy_denies_and_redacts_nodes() -> None:
graph = _load("memory-graph.json")
graph["nodes"][0]["policy"] = {"labels": ["project-local"], "trust_zone": "local"}
graph["nodes"][1]["policy"] = {"labels": ["project-local"], "trust_zone": "local", "reauthorization": "daily"}
graph["nodes"][2]["policy"] = {"labels": ["project-local"], "secret": True}
graph["nodes"][3]["policy"] = {"labels": ["restricted"]}
runtime = PhaseMemoryRuntime()
envelope = runtime.plan_activation(
graph,
max_items=4,
max_tokens=80,
policy_context={
"required_labels": ["project-local"],
"denied_labels": ["restricted"],
"trust_zone": "local",
"secrets_allowed": False,
"approved_reauthorizations": [],
},
)
selected = envelope["data"]["activation_plan"]["selected_node_ids"]
denials = envelope["data"]["policy_denials"]
assert selected == ["decision.boundary"]
assert [item["id"] for item in denials] == ["event.restart", "artifact.profile", "risk.durable-write"]
assert all(item["text"] == REDACTED for item in denials)
assert "activation_policy_denied" in [diagnostic["code"] for diagnostic in envelope["diagnostics"]]

82
tests/test_runtime.py Normal file
View File

@@ -0,0 +1,82 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from phase_memory.runtime import PACKAGE_REQUEST_SCHEMA, RUNTIME_ENVELOPE_SCHEMA, PhaseMemoryRuntime
FIXTURES = Path(__file__).parent / "fixtures"
def _load(name: str):
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def test_runtime_profile_plan_envelope_is_json_serializable() -> None:
runtime = PhaseMemoryRuntime()
envelope = runtime.plan_profile(_load("memory-profile.json"), source_ref="tests/fixtures/memory-profile.json")
assert envelope["schema_version"] == RUNTIME_ENVELOPE_SCHEMA
assert envelope["operation"] == "profile.plan"
assert envelope["valid"] is True
assert envelope["dry_run"] is True
assert envelope["subject"] == {"kind": "memory_profile", "id": "phase-memory-fixture-profile"}
assert envelope["policy_decision"]["allowed"] is True
assert envelope["audit_receipt"]["recorded"] is True
assert envelope["data"]["plan"]["ready"] is True
assert "activation.plan" in envelope["data"]["plan"]["capabilities"]
json.dumps(envelope, sort_keys=True)
def test_runtime_lifecycle_plan_collects_dry_run_actions() -> None:
runtime = PhaseMemoryRuntime()
envelope = runtime.plan_lifecycle(
_load("memory-graph.json"),
source_ref="tests/fixtures/memory-graph.json",
stale_after_days=7,
delete_after_days=30,
refresh_digests={"event.restart": "new"},
compact_node_ids=("event.restart", "risk.durable-write"),
now=datetime(2026, 5, 18, tzinfo=timezone.utc),
)
actions = envelope["data"]["dry_run_actions"]
assert envelope["operation"] == "graph.lifecycle.plan"
assert [action["action"] for action in actions] == ["mark_stale", "refresh", "compact"]
assert all("physical_delete" not in action.get("metadata", {}) or action["metadata"]["physical_delete"] is False for action in actions)
json.dumps(envelope, sort_keys=True)
def test_runtime_activation_plan_includes_package_request() -> None:
runtime = PhaseMemoryRuntime()
envelope = runtime.plan_activation(
_load("memory-graph.json"),
source_ref="tests/fixtures/memory-graph.json",
max_items=2,
max_tokens=18,
profile_id="phase-memory-fixture-profile",
priority_node_ids=("decision.boundary",),
)
activation = envelope["data"]["activation_plan"]
package_request = envelope["data"]["package_request"]
assert envelope["operation"] == "graph.activation.plan"
assert activation["selected_node_ids"][0] == "decision.boundary"
assert activation["selection"]["schema_version"] == "markitect.memory.selection.v1"
assert package_request["schema_version"] == PACKAGE_REQUEST_SCHEMA
assert package_request["dry_run"] is True
assert package_request["selection"]["id"] == activation["plan_id"]
def test_runtime_compile_package_wraps_compiler_response() -> None:
runtime = PhaseMemoryRuntime()
selection = {"schema_version": "markitect.memory.selection.v1", "id": "selection.a", "nodes": ["node.a"], "events": []}
envelope = runtime.compile_package(selection)
assert envelope["operation"] == "package.compile"
assert envelope["data"]["package_request"]["selection"] == selection
assert envelope["data"]["package_response"]["package_id"] == "package:selection.a"

View File

@@ -0,0 +1,70 @@
import json
from pathlib import Path
from phase_memory.runtime import PhaseMemoryRuntime
FIXTURES = Path(__file__).parent / "fixtures"
def _load(name: str):
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def test_profile_plan_runtime_snapshot() -> None:
runtime = PhaseMemoryRuntime()
envelope = runtime.plan_profile(_load("memory-profile.json"), source_ref="tests/fixtures/memory-profile.json")
assert _profile_projection(envelope) == _load("runtime-profile-plan-snapshot.json")
def test_activation_plan_runtime_snapshot() -> None:
runtime = PhaseMemoryRuntime()
envelope = runtime.plan_activation(
_load("memory-graph.json"),
source_ref="tests/fixtures/memory-graph.json",
max_items=2,
max_tokens=18,
profile_id="phase-memory-fixture-profile",
priority_node_ids=("decision.boundary",),
)
assert _activation_projection(envelope) == _load("runtime-activation-plan-snapshot.json")
def _profile_projection(envelope: dict) -> dict:
plan = envelope["data"]["plan"]
return {
"schema_version": envelope["schema_version"],
"operation": envelope["operation"],
"operation_id": envelope["operation_id"],
"valid": envelope["valid"],
"subject": envelope["subject"],
"diagnostic_codes": [diagnostic["code"] for diagnostic in envelope["diagnostics"]],
"data": {
"ready": plan["ready"],
"capabilities": plan["capabilities"],
"required_adapters": plan["required_adapters"],
"policy_gates": plan["policy_gates"],
},
}
def _activation_projection(envelope: dict) -> dict:
plan = envelope["data"]["activation_plan"]
return {
"schema_version": envelope["schema_version"],
"operation": envelope["operation"],
"operation_id": envelope["operation_id"],
"valid": envelope["valid"],
"subject": envelope["subject"],
"diagnostic_codes": [diagnostic["code"] for diagnostic in envelope["diagnostics"]],
"data": {
"plan_id": plan["plan_id"],
"selected_node_ids": plan["selected_node_ids"],
"selected_event_ids": plan["selected_event_ids"],
"omitted": [f"{item['id']}:{item['reason']}" for item in plan["omitted"]],
"token_estimate": plan["token_estimate"],
"package_request_id": envelope["data"]["package_request"]["id"],
},
}

View File

@@ -44,31 +44,94 @@ not what adjacent repositories may already provide.
## Current Baseline - 2026-05-18
Overall maturity: **2.0 / 5**
Overall maturity: **3.1 / 5**
The repo has crossed from intent-only into a working deterministic library
foundation. It is not yet a usable local runtime because there is no facade,
CLI, file-backed persistence, review-gated apply path, package compiler bridge,
or service contract.
foundation, a usable local runtime facade, a CLI, a file-backed local
workspace, and first-slice policy/review/audit gates. It is not yet an
interop-complete runtime because richer Markitect package bridge, activation
quality, and service contracts remain ahead.
| Dimension | Current | Target | Evidence | Needed Next |
| --- | ---: | ---: | --- | --- |
| Intent and boundaries | 4.0 | 5.0 | `INTENT.md`, `SCOPE.md`, `README.md`, architecture doc, PMEM-WP-0001 closure | Keep boundaries current as runtime behavior expands. |
| Package foundation | 2.5 | 4.0 | Python package, exports, dependency-light tests | Add runtime facade, stable public envelopes, CLI. |
| Profile contract ingress | 2.0 | 4.0 | Markitect-compatible profile loading and diagnostics | Add validation adapter boundary and compatibility fixture catalog. |
| Graph/event contract ingress | 2.0 | 4.0 | Graph loading, edge endpoint diagnostics, event model | Add richer event path modeling and import/export repair diagnostics. |
| Phase domain model | 2.5 | 4.0 | Phases, memory kinds, lifecycle states, actions | Add transition rules, approved apply behavior, path-aware state updates. |
| Profile execution planning | 2.5 | 4.0 | Adapter plan, capabilities, policy gates, fallback behavior | Add runtime orchestration, JSON snapshots, CLI outputs. |
| Lifecycle planning | 2.0 | 4.0 | Transition, retention, refresh, compaction dry-run plans | Add profile-driven rule evaluation and review-gated apply. |
| Activation planning | 2.0 | 5.0 | Budgeted selection and Markitect-compatible selection output | Add graph neighborhoods, event paths, ranking, metadata preservation, metrics. |
| Local persistence | 1.0 | 4.0 | In-memory adapters only | Add versioned file-backed graph store and JSONL event log. |
| Policy and audit | 1.5 | 5.0 | Policy/audit ports, allow-all gateway, recording sink, review flags | Add enforcement points, review records, redaction, audit schema. |
| Observability and diagnostics | 1.5 | 4.0 | Planner diagnostics and observability event names | Add audit/health envelopes and adapter status diagnostics. |
| Package foundation | 3.0 | 4.0 | Python package, exports, runtime facade, CLI entrypoint, dependency-light tests | Add local persistence and richer adapter configuration. |
| Profile contract ingress | 2.5 | 4.0 | Markitect-compatible profile loading, diagnostics, runtime envelopes | Add validation adapter boundary and compatibility fixture catalog. |
| Graph/event contract ingress | 3.0 | 4.0 | Graph loading, edge endpoint diagnostics, event model, JSONL event log, export, repair diagnostics | Add richer policy-aware import/export checks. |
| Phase domain model | 3.0 | 4.0 | Phases, memory kinds, lifecycle states, actions, explicit path records | Add transition rule profiles and review records. |
| Profile execution planning | 3.0 | 4.0 | Adapter plan, capabilities, policy gates, fallback behavior, CLI output, snapshot fixture | Add profile-driven runtime configuration and compatibility validation. |
| Lifecycle planning | 3.0 | 4.0 | Transition, retention, refresh, compaction dry-run plans, review-gated local apply | Add profile-driven rule evaluation and full review records. |
| Activation planning | 2.5 | 5.0 | Budgeted selection, Markitect-compatible selection output, package request envelope, CLI output | Add graph neighborhoods, event paths, ranking, metadata preservation, metrics. |
| Local persistence | 3.0 | 4.0 | Versioned local workspace, file-backed graph store, JSONL event log, JSONL audit sink | Add migration/repair utilities and stronger durability semantics. |
| Policy and audit | 3.2 | 5.0 | Operation points, policy gateway checks, audit schema, review records, redaction, activation denials | Add external policy adapters and richer audit retention behavior. |
| Observability and diagnostics | 2.5 | 4.0 | Planner diagnostics, runtime diagnostics, event log corruption checks, repair diagnostics, policy denial diagnostics | Add health envelopes and adapter status diagnostics. |
| Markitect interop | 1.5 | 4.0 | Compatible schema constants and selection handoff | Add package bridge envelopes, optional validation/compiler adapters. |
| Kontextual/Infospace interop | 1.0 | 4.0 | Boundaries documented and small derived fixtures | Add delegation envelope design and evaluation fixture reports. |
| Testing and evaluation | 2.0 | 4.0 | 13 deterministic tests over core planners/adapters | Add CLI snapshots, file-store round trips, policy denial, activation metrics. |
| Testing and evaluation | 3.2 | 4.0 | 36 deterministic tests over planners, adapters, runtime envelopes, CLI, snapshots, file-store round trips, apply denial, review records, audit schema, and policy redaction | Add activation metrics. |
| Service readiness | 0.5 | 4.0 | Runtime ports exist | Add service contracts, config, health checks, adapter conformance tests. |
| Developer experience | 2.0 | 4.0 | README quick start and package map | Add CLI guide, local persistence guide, examples, troubleshooting. |
| Developer experience | 3.3 | 4.0 | README quick start, package map, runtime facade docs, CLI examples, local persistence guide | Add troubleshooting and richer examples. |
## Progress Update - PMEM-WP-0002
Closed on 2026-05-18:
- Added `PhaseMemoryRuntime` as the local application facade.
- Added JSON runtime envelopes with policy decisions, audit receipts,
diagnostics, dry-run flags, operation ids, and source references.
- Added `phase-memory` console script metadata and CLI commands for profile
planning, graph lifecycle planning, and graph activation planning.
- Added snapshot fixtures for profile-plan and activation-plan envelope shapes.
- Updated local usage and architecture docs.
Remaining maturity blockers:
- File-backed persistence and event path runtime.
- Review-gated apply behavior.
- Policy enforcement and redaction beyond the allow-all local adapter.
- Stronger Markitect compiler/validation bridge.
- Activation quality evaluation.
- Service contracts and external adapter conformance.
## Progress Update - PMEM-WP-0003
Closed on 2026-05-18:
- Added a versioned local file-backed workspace layout.
- Added deterministic file-backed profile, node, edge, and path storage.
- Added append-only JSONL event and audit adapters.
- Added graph export, event replay, and repair diagnostics.
- Added explicit conversational path records and path-event helpers.
- Added review-gated lifecycle apply behavior for local stores.
- Added store import/export/repair CLI coverage and local persistence docs.
Remaining maturity blockers:
- Policy operation vocabulary and review records.
- Activation-time policy checks and redaction.
- Stable audit event schema.
- Markitect compiler/validation bridge.
- Activation quality metrics.
- Service readiness and external adapter conformance.
## Progress Update - PMEM-WP-0004
Closed on 2026-05-18:
- Added canonical memory operation policy points.
- Added review records and deterministic review ids.
- Added stable audit event schema `phase_memory.audit.event.v1`.
- Added review-record enforcement for lifecycle apply.
- Added activation policy checks for labels, denied labels, trust zones,
secrets, reauthorization, and freshness.
- Added deterministic redaction records and diagnostics for denied activation
items.
- Added policy/audit documentation.
Remaining maturity blockers:
- Optional Markitect validation and package compiler bridge.
- Activation ranking and evaluation metrics.
- Service contracts, health diagnostics, and external adapter conformance.
## Score Movement Rules

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Local Runtime Facade And CLI"
domain: markitect
repo: phase-memory
status: proposed
status: finished
owner: phase-memory
topic_slug: local-runtime
planning_priority: P1
@@ -50,11 +50,34 @@ together and there is no command-line path for inspecting plans.
- Do not start a long-lived HTTP service in this workplan.
- Do not add live LLM, vector, or graph database dependencies.
## Implementation Update - 2026-05-18
The local runtime and CLI slice is complete.
Implemented outputs:
- `src/phase_memory/runtime.py` defines `PhaseMemoryRuntime` with stable
runtime envelopes for profile import, graph import, profile planning,
lifecycle planning, activation planning, and package compilation handoff.
- `src/phase_memory/cli.py` adds dependency-light `argparse` commands for
profile planning, graph lifecycle planning, and graph activation planning.
- `pyproject.toml` exposes the installed console script as `phase-memory`.
- Runtime and CLI tests cover JSON envelopes, dry-run lifecycle actions,
package requests, summary output, and console-script metadata.
- Snapshot fixtures pin the public profile-plan and activation-plan envelope
shape without freezing internal dataclass details.
- `README.md` and `docs/architecture.md` document local usage, the facade
boundary, dry-run guarantees, and adjacent-repo expectations.
Validation:
- `python3 -m pytest` -> 23 passed.
## T01 - Add a local runtime facade
```task
id: PMEM-WP-0002-T01
status: todo
status: done
priority: high
state_hub_task_id: "456557a9-3ac3-483b-bbdd-5591224894b9"
```
@@ -78,7 +101,7 @@ usable, but gives integrations one obvious local entrypoint.
```task
id: PMEM-WP-0002-T02
status: todo
status: done
priority: high
state_hub_task_id: "b04054cb-d743-4fcd-9b37-2685d1f9c00d"
```
@@ -100,7 +123,7 @@ ids, dry-run flags, and source contract references.
```task
id: PMEM-WP-0002-T03
status: todo
status: done
priority: high
state_hub_task_id: "8463924e-a6ce-43f1-b7fc-544a2aa7fd5f"
```
@@ -120,7 +143,7 @@ Output: CLI command, tests around success and diagnostics, and README usage.
```task
id: PMEM-WP-0002-T04
status: todo
status: done
priority: high
state_hub_task_id: "ab818835-5ef3-4a43-adf2-444ab712ead9"
```
@@ -142,7 +165,7 @@ refresh, and compaction proposals.
```task
id: PMEM-WP-0002-T05
status: todo
status: done
priority: medium
state_hub_task_id: "9c8e8511-f00a-4685-91b5-a52c93d8461d"
```
@@ -161,7 +184,7 @@ Output: activation command, deterministic output tests, and README usage.
```task
id: PMEM-WP-0002-T06
status: todo
status: done
priority: medium
state_hub_task_id: "a005aa31-053e-4b51-b3ea-3bebf24ac833"
```
@@ -176,7 +199,7 @@ Output: stable examples for downstream repos and future compatibility checks.
```task
id: PMEM-WP-0002-T07
status: todo
status: done
priority: medium
state_hub_task_id: "f6c6c6b2-141a-44da-a3d5-dbef95559049"
```
@@ -200,3 +223,26 @@ five minutes.
the CLI against existing fixtures.
- Runtime outputs are deterministic and JSON-serializable.
- No default path mutates durable memory stores.
## Closure Review - 2026-05-18
**Outcome:** All tasks completed.
### Completed
- PMEM-WP-0002-T01 - Add a local runtime facade
- PMEM-WP-0002-T02 - Define runtime input and output envelopes
- PMEM-WP-0002-T03 - Implement profile planning CLI
- PMEM-WP-0002-T04 - Implement graph lifecycle CLI
- PMEM-WP-0002-T05 - Implement activation CLI
- PMEM-WP-0002-T06 - Add snapshot tests for CLI and runtime envelopes
- PMEM-WP-0002-T07 - Update documentation for local usage
### Cancelled
None.
### Carried Forward
Local persistence, review-gated apply behavior, richer policy enforcement, and
external adapter readiness remain in PMEM-WP-0003 through PMEM-WP-0007.

View File

@@ -4,7 +4,7 @@ type: workplan
title: "File-Backed Stores And Event Path Runtime"
domain: markitect
repo: phase-memory
status: proposed
status: finished
owner: phase-memory
topic_slug: local-persistence
planning_priority: P1
@@ -42,11 +42,36 @@ first-class runtime objects.
- Do not add hidden background compaction or deletion.
- Do not make file-backed storage the only adapter option.
## Implementation Update - 2026-05-18
The local persistence and event-path runtime slice is complete.
Implemented outputs:
- `FileBackedMemoryGraphStore` stores profiles, nodes, edges, and path records
as deterministic JSON under a versioned local workspace layout.
- `JsonlMemoryEventLog` provides append-only event logging, duplicate event id
detection, kind filtering, graph replay, and corruption/schema diagnostics.
- `JsonlAuditSink` records local runtime audit events into `audit.jsonl`.
- `MemoryPath` and `phase_memory.paths` model branch, merge, abandon, compact,
and structured path-event behavior without transcript storage.
- `PhaseMemoryRuntime` can export local graph state, report repair diagnostics,
and apply lifecycle actions only when review-required actions include an
explicit approval marker.
- CLI store commands can import fixtures, export local graph envelopes, and
report repair diagnostics.
- `docs/local-persistence.md` documents the storage layout, CLI flow, path
model, and review-gated apply rule.
Validation:
- `python3 -m pytest` -> 31 passed.
## T01 - Define local storage layout
```task
id: PMEM-WP-0003-T01
status: todo
status: done
priority: high
state_hub_task_id: "37d082c7-c019-4ecd-8655-94f8f27807ff"
```
@@ -70,7 +95,7 @@ Output: documented storage layout, schema version fields, and fixture examples.
```task
id: PMEM-WP-0003-T02
status: todo
status: done
priority: high
state_hub_task_id: "c417f7ec-0423-4723-91c3-3b4681e30ec3"
```
@@ -90,7 +115,7 @@ Output: adapter implementation and tests for round-trip behavior.
```task
id: PMEM-WP-0003-T03
status: todo
status: done
priority: high
state_hub_task_id: "1d3b3ffb-fc9b-401f-a77f-cfad4a4f6b72"
```
@@ -109,7 +134,7 @@ Output: event log adapter and tests for append, list, replay, and diagnostics.
```task
id: PMEM-WP-0003-T04
status: todo
status: done
priority: high
state_hub_task_id: "43d5863c-f2db-441e-8f3e-7e1843b6bc33"
```
@@ -131,7 +156,7 @@ abandon flows without requiring transcript storage.
```task
id: PMEM-WP-0003-T05
status: todo
status: done
priority: medium
state_hub_task_id: "e9079c0c-5834-47b2-b1dc-1d97604c96f8"
```
@@ -147,7 +172,7 @@ unapproved durable actions are denied.
```task
id: PMEM-WP-0003-T06
status: todo
status: done
priority: medium
state_hub_task_id: "a0597e3e-2f2d-4abf-8fc2-37c914e0ce34"
```
@@ -166,7 +191,7 @@ Output: CLI/runtime helpers and tests for useful diagnostics.
```task
id: PMEM-WP-0003-T07
status: todo
status: done
priority: medium
state_hub_task_id: "60b3bce7-3e73-427b-95d7-d68283503a3c"
```
@@ -186,3 +211,26 @@ Output: local persistence guide and README pointers.
marker.
- Conversational path branches and merges are represented as structured memory
events, not only as transcript text.
## Closure Review - 2026-05-18
**Outcome:** All tasks completed.
### Completed
- PMEM-WP-0003-T01 - Define local storage layout
- PMEM-WP-0003-T02 - Implement a file-backed graph store
- PMEM-WP-0003-T03 - Implement a JSONL event log
- PMEM-WP-0003-T04 - Model conversational paths explicitly
- PMEM-WP-0003-T05 - Add safe apply behavior behind review gates
- PMEM-WP-0003-T06 - Add import, export, and repair diagnostics
- PMEM-WP-0003-T07 - Update docs with local persistence examples
### Cancelled
None.
### Carried Forward
Policy enforcement, review record modeling, activation redaction, and richer
audit schema remain in PMEM-WP-0004.

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Policy, Audit, And Review Gates"
domain: markitect
repo: phase-memory
status: proposed
status: finished
owner: phase-memory
topic_slug: policy-audit
planning_priority: P1
@@ -52,11 +52,38 @@ activations, writes, compactions, and phase transitions.
- Do not make `flex-auth` a hard dependency.
- Do not store or expose secrets in test fixtures.
## Implementation Update - 2026-05-18
The policy, audit, and review-gate slice is complete.
Implemented outputs:
- `phase_memory.policy` defines canonical memory operation points, audit event
schema helpers, review-record helpers, activation policy checks, and
deterministic redaction.
- `ReviewRecord` and `ReviewDecision` model structured local approvals and
rejections for review-gated lifecycle actions.
- Runtime audit events now use `phase_memory.audit.event.v1` and carry policy
decisions, source references, dry-run flags, actor labels, and subjects.
- `apply_lifecycle_actions` rejects review-required actions unless the caller
provides a matching approved review record or explicit local approval marker.
- Activation planning can evaluate required labels, denied labels, trust zones,
secret denial, reauthorization, and freshness policy context before package
selection.
- Policy-denied activation nodes are omitted and returned as redacted denial
records with diagnostics.
- `docs/policy-audit.md` documents operation points, review records,
activation policy, audit envelopes, and ownership boundaries.
Validation:
- `python3 -m pytest` -> 36 passed.
## T01 - Define memory operation policy points
```task
id: PMEM-WP-0004-T01
status: todo
status: done
priority: high
state_hub_task_id: "1231b7bf-b23c-498d-a9d6-a6ee307aa3d4"
```
@@ -82,7 +109,7 @@ the right boundary.
```task
id: PMEM-WP-0004-T02
status: todo
status: done
priority: high
state_hub_task_id: "b989d43c-eb25-4663-afd1-a54673ad565a"
```
@@ -107,7 +134,7 @@ Output: review record model and approval checks in the runtime facade.
```task
id: PMEM-WP-0004-T03
status: todo
status: done
priority: high
state_hub_task_id: "6b677c18-7135-4d54-9e46-5116645d2ebe"
```
@@ -122,7 +149,7 @@ and deletion requests cannot be applied silently.
```task
id: PMEM-WP-0004-T04
status: todo
status: done
priority: high
state_hub_task_id: "6f07087b-e6e2-469a-9bce-71bfd21cb633"
```
@@ -143,7 +170,7 @@ for policy-denied records.
```task
id: PMEM-WP-0004-T05
status: todo
status: done
priority: medium
state_hub_task_id: "bb6461a8-9181-4b88-a152-334668b22208"
```
@@ -169,7 +196,7 @@ apply operations.
```task
id: PMEM-WP-0004-T06
status: todo
status: done
priority: medium
state_hub_task_id: "dcdec3af-d20f-43ba-b12e-6febc4347d38"
```
@@ -183,7 +210,7 @@ Output: redaction utility, denied activation examples, and regression tests.
```task
id: PMEM-WP-0004-T07
status: todo
status: done
priority: medium
state_hub_task_id: "c4e0bdff-5047-4fe5-ab86-e422d4b1a17e"
```
@@ -202,3 +229,26 @@ review-required operations.
- Review-required actions fail closed without an explicit review record.
- The policy layer remains adapter-based and does not become an identity
platform.
## Closure Review - 2026-05-18
**Outcome:** All tasks completed.
### Completed
- PMEM-WP-0004-T01 - Define memory operation policy points
- PMEM-WP-0004-T02 - Add review authorization records
- PMEM-WP-0004-T03 - Enforce durable write gates
- PMEM-WP-0004-T04 - Add activation policy checks
- PMEM-WP-0004-T05 - Add audit event schema
- PMEM-WP-0004-T06 - Add redaction and denial diagnostics
- PMEM-WP-0004-T07 - Document policy and audit guarantees
### Cancelled
None.
### Carried Forward
Optional Markitect validation and context-package compiler bridge behavior
remain in PMEM-WP-0005.