From 8089a7c8fa751bf8a230d034d3f438a60c88631f Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 18 May 2026 18:21:27 +0200 Subject: [PATCH] Implement local runtime persistence and policy gates --- README.md | 43 +- docs/architecture.md | 43 ++ docs/local-persistence.md | 91 ++++ docs/policy-audit.md | 113 +++++ pyproject.toml | 3 + src/phase_memory/__init__.py | 21 + src/phase_memory/adapters.py | 189 ++++++- src/phase_memory/cli.py | 196 ++++++++ src/phase_memory/models.py | 103 ++++ src/phase_memory/paths.py | 47 ++ src/phase_memory/policy.py | 145 ++++++ src/phase_memory/runtime.py | 471 ++++++++++++++++++ .../runtime-activation-plan-snapshot.json | 29 ++ .../runtime-profile-plan-snapshot.json | 33 ++ tests/test_cli.py | 112 +++++ tests/test_file_backed_runtime.py | 122 +++++ tests/test_policy_runtime.py | 105 ++++ tests/test_runtime.py | 82 +++ tests/test_runtime_snapshots.py | 70 +++ workplans/PMEM-MATURITY-SCORECARD.md | 95 +++- ...EM-WP-0002-local-runtime-facade-and-cli.md | 62 ++- ...le-backed-stores-and-event-path-runtime.md | 64 ++- ...M-WP-0004-policy-audit-and-review-gates.md | 66 ++- 23 files changed, 2263 insertions(+), 42 deletions(-) create mode 100644 docs/local-persistence.md create mode 100644 docs/policy-audit.md create mode 100644 src/phase_memory/cli.py create mode 100644 src/phase_memory/paths.py create mode 100644 src/phase_memory/policy.py create mode 100644 src/phase_memory/runtime.py create mode 100644 tests/fixtures/runtime-activation-plan-snapshot.json create mode 100644 tests/fixtures/runtime-profile-plan-snapshot.json create mode 100644 tests/test_cli.py create mode 100644 tests/test_file_backed_runtime.py create mode 100644 tests/test_policy_runtime.py create mode 100644 tests/test_runtime.py create mode 100644 tests/test_runtime_snapshots.py diff --git a/README.md b/README.md index 6d0b889..67da1ef 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 95d30c3..eb53cc9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/local-persistence.md b/docs/local-persistence.md new file mode 100644 index 0000000..d7a4dfd --- /dev/null +++ b/docs/local-persistence.md @@ -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/ + .json + nodes/ + .json + edges/ + .json + paths/ + .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. diff --git a/docs/policy-audit.md b/docs/policy-audit.md new file mode 100644 index 0000000..c760160 --- /dev/null +++ b/docs/policy-audit.md @@ -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: +``` + +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. diff --git a/pyproject.toml b/pyproject.toml index d0a32b5..20dce25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ authors = [ ] dependencies = [] +[project.scripts] +phase-memory = "phase_memory.cli:main" + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/phase_memory/__init__.py b/src/phase_memory/__init__.py index 1d7e4c2..6024d8d 100644 --- a/src/phase_memory/__init__.py +++ b/src/phase_memory/__init__.py @@ -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" diff --git a/src/phase_memory/adapters.py b/src/phase_memory/adapters.py index 41510dd..89a34fe 100644 --- a/src/phase_memory/adapters.py +++ b/src/phase_memory/adapters.py @@ -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/.json` + - `nodes/.json` + - `edges/.json` + - `paths/.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") diff --git a/src/phase_memory/cli.py b/src/phase_memory/cli.py new file mode 100644 index 0000000..feb0704 --- /dev/null +++ b/src/phase_memory/cli.py @@ -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:])) diff --git a/src/phase_memory/models.py b/src/phase_memory/models.py index 722610d..24afc71 100644 --- a/src/phase_memory/models.py +++ b/src/phase_memory/models.py @@ -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 diff --git a/src/phase_memory/paths.py b/src/phase_memory/paths.py new file mode 100644 index 0000000..e6d0b05 --- /dev/null +++ b/src/phase_memory/paths.py @@ -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 {}), + }, + ) diff --git a/src/phase_memory/policy.py b/src/phase_memory/policy.py new file mode 100644 index 0000000..2883d94 --- /dev/null +++ b/src/phase_memory/policy.py @@ -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] diff --git a/src/phase_memory/runtime.py b/src/phase_memory/runtime.py new file mode 100644 index 0000000..d5691bd --- /dev/null +++ b/src/phase_memory/runtime.py @@ -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 diff --git a/tests/fixtures/runtime-activation-plan-snapshot.json b/tests/fixtures/runtime-activation-plan-snapshot.json new file mode 100644 index 0000000..7e47ae6 --- /dev/null +++ b/tests/fixtures/runtime-activation-plan-snapshot.json @@ -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 +} diff --git a/tests/fixtures/runtime-profile-plan-snapshot.json b/tests/fixtures/runtime-profile-plan-snapshot.json new file mode 100644 index 0000000..df34237 --- /dev/null +++ b/tests/fixtures/runtime-profile-plan-snapshot.json @@ -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 +} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7b43499 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_file_backed_runtime.py b/tests/test_file_backed_runtime.py new file mode 100644 index 0000000..06f147d --- /dev/null +++ b/tests/test_file_backed_runtime.py @@ -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 diff --git a/tests/test_policy_runtime.py b/tests/test_policy_runtime.py new file mode 100644 index 0000000..dafc6cf --- /dev/null +++ b/tests/test_policy_runtime.py @@ -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"]] diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..da5e356 --- /dev/null +++ b/tests/test_runtime.py @@ -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" diff --git a/tests/test_runtime_snapshots.py b/tests/test_runtime_snapshots.py new file mode 100644 index 0000000..8b6d8eb --- /dev/null +++ b/tests/test_runtime_snapshots.py @@ -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"], + }, + } diff --git a/workplans/PMEM-MATURITY-SCORECARD.md b/workplans/PMEM-MATURITY-SCORECARD.md index 8d04535..6bb765e 100644 --- a/workplans/PMEM-MATURITY-SCORECARD.md +++ b/workplans/PMEM-MATURITY-SCORECARD.md @@ -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 diff --git a/workplans/PMEM-WP-0002-local-runtime-facade-and-cli.md b/workplans/PMEM-WP-0002-local-runtime-facade-and-cli.md index 7ca9b08..a497995 100644 --- a/workplans/PMEM-WP-0002-local-runtime-facade-and-cli.md +++ b/workplans/PMEM-WP-0002-local-runtime-facade-and-cli.md @@ -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. diff --git a/workplans/PMEM-WP-0003-file-backed-stores-and-event-path-runtime.md b/workplans/PMEM-WP-0003-file-backed-stores-and-event-path-runtime.md index 4b0bcd1..494fc20 100644 --- a/workplans/PMEM-WP-0003-file-backed-stores-and-event-path-runtime.md +++ b/workplans/PMEM-WP-0003-file-backed-stores-and-event-path-runtime.md @@ -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. diff --git a/workplans/PMEM-WP-0004-policy-audit-and-review-gates.md b/workplans/PMEM-WP-0004-policy-audit-and-review-gates.md index ef45a96..276134f 100644 --- a/workplans/PMEM-WP-0004-policy-audit-and-review-gates.md +++ b/workplans/PMEM-WP-0004-policy-audit-and-review-gates.md @@ -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.