generated from coulomb/repo-seed
Implement local runtime persistence and policy gates
This commit is contained in:
43
README.md
43
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.
|
||||
|
||||
@@ -146,6 +146,49 @@ The slice should emit plans first, not mutate durable memory by surprise.
|
||||
Durable writes, external adapters, live LLM extraction, vector retrieval, and
|
||||
service deployment can follow once the plan model is stable.
|
||||
|
||||
## Local Runtime Facade
|
||||
|
||||
The second implementation slice adds a local facade, `PhaseMemoryRuntime`, over
|
||||
the deterministic core. The facade is not a service runner. It is the small
|
||||
application surface that adjacent tools can call before a service deployment
|
||||
exists.
|
||||
|
||||
Runtime operations currently include:
|
||||
|
||||
- profile import
|
||||
- graph import
|
||||
- profile execution planning
|
||||
- graph lifecycle planning
|
||||
- graph activation planning
|
||||
- package compilation handoff through `ContextPackageCompiler`
|
||||
|
||||
Each operation returns a JSON-serializable envelope with:
|
||||
|
||||
- `schema_version`
|
||||
- `operation_id`
|
||||
- `operation`
|
||||
- `dry_run`
|
||||
- `valid`
|
||||
- `subject`
|
||||
- `source`
|
||||
- `policy_decision`
|
||||
- `audit_receipt`
|
||||
- `diagnostics`
|
||||
- `data`
|
||||
|
||||
The local CLI exposes the same facade for fixtures and developer workflows:
|
||||
|
||||
```bash
|
||||
phase-memory profile plan profile.json
|
||||
phase-memory graph lifecycle graph.json --stale-after-days 7 --delete-after-days 30
|
||||
phase-memory graph activate graph.json --max-items 3 --max-tokens 60
|
||||
```
|
||||
|
||||
The default implementation uses in-memory stores, an allow-all local policy
|
||||
gateway, a recording audit sink, and a noop context-package compiler. These are
|
||||
test and integration adapters, not a claim that durable persistence, policy, or
|
||||
package internals belong in this repository.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should `phase-memory` depend directly on `markitect-tool` for validation, or
|
||||
|
||||
91
docs/local-persistence.md
Normal file
91
docs/local-persistence.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Local Persistence
|
||||
|
||||
`phase-memory` can run against a versioned local file workspace. This is a
|
||||
developer and integration adapter, not a production graph database.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
memory-store/
|
||||
phase-memory.json
|
||||
profiles/
|
||||
<profile-id>.json
|
||||
nodes/
|
||||
<node-id>.json
|
||||
edges/
|
||||
<edge-id>.json
|
||||
paths/
|
||||
<path-id>.json
|
||||
activations/
|
||||
events.jsonl
|
||||
audit.jsonl
|
||||
```
|
||||
|
||||
The root `phase-memory.json` declares:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "phase_memory.local_store.v1"
|
||||
}
|
||||
```
|
||||
|
||||
Profiles, nodes, edges, and paths are stored as deterministic JSON files.
|
||||
Events and audit records are append-only JSONL files. The current local runtime
|
||||
does not compact, delete, or rewrite append-only logs.
|
||||
|
||||
## CLI
|
||||
|
||||
Import local fixtures:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m phase_memory.cli store import \
|
||||
--store .phase-memory-local \
|
||||
--profile tests/fixtures/memory-profile.json \
|
||||
--graph tests/fixtures/memory-graph.json
|
||||
```
|
||||
|
||||
Export a Markitect-compatible graph envelope:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m phase_memory.cli store export \
|
||||
--store .phase-memory-local \
|
||||
--graph-id local-dev
|
||||
```
|
||||
|
||||
Inspect repair diagnostics:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m phase_memory.cli store repair \
|
||||
--store .phase-memory-local
|
||||
```
|
||||
|
||||
Repair diagnostics report malformed JSONL event lines, unknown event schema
|
||||
versions, missing edge endpoints, and path records that reference events not
|
||||
present in the event log.
|
||||
|
||||
## Paths
|
||||
|
||||
Conversational paths are structured records, not transcript blobs. A path can
|
||||
record:
|
||||
|
||||
- `path_id`
|
||||
- `parent_path_id`
|
||||
- ordered `event_ids`
|
||||
- active, merged, abandoned, or compacted state
|
||||
- merge target
|
||||
- abandoned reason
|
||||
- compacted summary id
|
||||
|
||||
Helper functions in `phase_memory.paths` create, branch, merge, abandon, and
|
||||
compact paths while also producing structured path events for the fluid memory
|
||||
event log.
|
||||
|
||||
## Review-Gated Apply
|
||||
|
||||
Lifecycle planning remains dry-run by default. The runtime exposes an optional
|
||||
`apply_lifecycle_actions` operation for local stores. Actions marked
|
||||
`requires_review` are denied unless the caller provides an explicit
|
||||
`approval_marker`.
|
||||
|
||||
This keeps the local adapter useful for development while preserving the
|
||||
project rule that durable memory changes must be inspectable and deliberate.
|
||||
113
docs/policy-audit.md
Normal file
113
docs/policy-audit.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Policy And Audit
|
||||
|
||||
`phase-memory` keeps policy enforcement adapter-based. The local runtime
|
||||
defines deterministic operation points, review records, audit envelopes, and
|
||||
redaction behavior without becoming an identity or authorization platform.
|
||||
|
||||
## Operation Points
|
||||
|
||||
Canonical operation names live in `phase_memory.policy.MemoryOperation`.
|
||||
|
||||
Current operation points include:
|
||||
|
||||
- `profile.import`
|
||||
- `graph.import`
|
||||
- `node.read`
|
||||
- `event.read`
|
||||
- `profile.plan`
|
||||
- `graph.lifecycle.plan`
|
||||
- `graph.activation.plan`
|
||||
- `package.compile`
|
||||
- `lifecycle.apply`
|
||||
- `memory.stabilize`
|
||||
- `memory.compact`
|
||||
- `memory.refresh`
|
||||
- `memory.delete_request`
|
||||
- `memory.archive`
|
||||
- `graph.export`
|
||||
- `store.repair.diagnostics`
|
||||
|
||||
Runtime operations call the configured `PolicyGateway` before emitting an
|
||||
envelope. The default local adapter is allow-all and exists only for
|
||||
dependency-light tests and local development.
|
||||
|
||||
## Review Records
|
||||
|
||||
Review-required lifecycle actions fail closed unless a caller provides an
|
||||
approved review record or the legacy local `approval_marker` shorthand.
|
||||
|
||||
Review records capture:
|
||||
|
||||
- review id
|
||||
- reviewed action id
|
||||
- reviewer
|
||||
- approval or rejection
|
||||
- timestamp
|
||||
- reason
|
||||
- obligations
|
||||
- source digests
|
||||
|
||||
The reviewed action id is deterministic:
|
||||
|
||||
```text
|
||||
action:<digest-of-lifecycle-action>
|
||||
```
|
||||
|
||||
This lets the runtime reject a review record that was issued for a different
|
||||
planned action.
|
||||
|
||||
## Activation Policy
|
||||
|
||||
Activation planning can receive a local policy context:
|
||||
|
||||
```python
|
||||
runtime.plan_activation(
|
||||
graph,
|
||||
max_items=4,
|
||||
max_tokens=80,
|
||||
policy_context={
|
||||
"required_labels": ["project-local"],
|
||||
"denied_labels": ["restricted"],
|
||||
"trust_zone": "local",
|
||||
"secrets_allowed": False,
|
||||
"approved_reauthorizations": [],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Nodes denied by policy are omitted before selection. The runtime returns
|
||||
diagnostics with code `activation_policy_denied` and a deterministic redacted
|
||||
record where policy-sensitive fields and text are replaced with `[REDACTED]`.
|
||||
|
||||
## Audit Envelope
|
||||
|
||||
Audit events use schema `phase_memory.audit.event.v1` and include:
|
||||
|
||||
- operation id
|
||||
- operation kind
|
||||
- subject id
|
||||
- profile id
|
||||
- graph id
|
||||
- policy decision
|
||||
- dry-run flag
|
||||
- planned action id
|
||||
- actor label
|
||||
- timestamp
|
||||
- source reference
|
||||
|
||||
The local audit sinks record these events either in memory or as append-only
|
||||
JSONL. External audit systems should implement the `AuditSink` port.
|
||||
|
||||
## Boundaries
|
||||
|
||||
This repository does not own:
|
||||
|
||||
- user identity
|
||||
- enterprise policy languages
|
||||
- remote policy decision points
|
||||
- long-term audit retention
|
||||
- legal hold or compliance workflows
|
||||
|
||||
Those belong behind adapters. `phase-memory` owns the memory-native points
|
||||
where policy, review, redaction, and audit decisions must be requested and
|
||||
explained.
|
||||
@@ -14,6 +14,9 @@ authors = [
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
phase-memory = "phase_memory.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import MemoryEdge, MemoryEvent, MemoryNode, PolicyDecision, ProfileIntent
|
||||
from .models import Diagnostic, MemoryEdge, MemoryEvent, MemoryGraph, MemoryNode, MemoryPath, PolicyDecision, ProfileIntent
|
||||
|
||||
LOCAL_STORE_SCHEMA = "phase_memory.local_store.v1"
|
||||
|
||||
|
||||
class InMemoryMemoryGraphStore:
|
||||
@@ -63,6 +67,160 @@ class InMemoryMemoryEventLog:
|
||||
return events
|
||||
|
||||
|
||||
class FileBackedMemoryGraphStore:
|
||||
"""Versioned JSON-backed local graph store.
|
||||
|
||||
Layout:
|
||||
- `phase-memory.json`
|
||||
- `profiles/<profile-id>.json`
|
||||
- `nodes/<node-id>.json`
|
||||
- `edges/<edge-id>.json`
|
||||
- `paths/<path-id>.json`
|
||||
- `activations/`
|
||||
- `audit.jsonl`
|
||||
"""
|
||||
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
self.root = Path(root)
|
||||
self.profiles_dir = self.root / "profiles"
|
||||
self.nodes_dir = self.root / "nodes"
|
||||
self.edges_dir = self.root / "edges"
|
||||
self.paths_dir = self.root / "paths"
|
||||
self.activations_dir = self.root / "activations"
|
||||
self.audit_path = self.root / "audit.jsonl"
|
||||
self._ensure_layout()
|
||||
|
||||
def save_profile(self, profile: ProfileIntent) -> ProfileIntent:
|
||||
_write_json(self.profiles_dir / f"{_safe_name(profile.profile_id)}.json", profile.to_dict())
|
||||
return profile
|
||||
|
||||
def get_profile(self, profile_id: str) -> ProfileIntent:
|
||||
return ProfileIntent.from_mapping(_read_json(self.profiles_dir / f"{_safe_name(profile_id)}.json"))
|
||||
|
||||
def save_node(self, node: MemoryNode) -> MemoryNode:
|
||||
_write_json(self.nodes_dir / f"{_safe_name(node.node_id)}.json", node.to_dict())
|
||||
return node
|
||||
|
||||
def get_node(self, node_id: str) -> MemoryNode:
|
||||
return MemoryNode.from_mapping(_read_json(self.nodes_dir / f"{_safe_name(node_id)}.json"))
|
||||
|
||||
def list_nodes(self, *, kind: str | None = None) -> list[MemoryNode]:
|
||||
nodes = [MemoryNode.from_mapping(_read_json(path)) for path in sorted(self.nodes_dir.glob("*.json"))]
|
||||
if kind:
|
||||
nodes = [node for node in nodes if node.kind == kind]
|
||||
return sorted(nodes, key=lambda node: node.node_id)
|
||||
|
||||
def save_edge(self, edge: MemoryEdge) -> MemoryEdge:
|
||||
_write_json(self.edges_dir / f"{_safe_name(edge.edge_id)}.json", edge.to_dict())
|
||||
return edge
|
||||
|
||||
def list_edges(self, *, source: str | None = None, target: str | None = None) -> list[MemoryEdge]:
|
||||
edges = [MemoryEdge.from_mapping(_read_json(path)) for path in sorted(self.edges_dir.glob("*.json"))]
|
||||
if source:
|
||||
edges = [edge for edge in edges if edge.source == source]
|
||||
if target:
|
||||
edges = [edge for edge in edges if edge.target == target]
|
||||
return sorted(edges, key=lambda edge: edge.edge_id)
|
||||
|
||||
def save_path(self, path: MemoryPath) -> MemoryPath:
|
||||
_write_json(self.paths_dir / f"{_safe_name(path.path_id)}.json", path.to_dict())
|
||||
return path
|
||||
|
||||
def get_path(self, path_id: str) -> MemoryPath:
|
||||
return MemoryPath.from_mapping(_read_json(self.paths_dir / f"{_safe_name(path_id)}.json"))
|
||||
|
||||
def list_paths(self) -> list[MemoryPath]:
|
||||
return [MemoryPath.from_mapping(_read_json(path)) for path in sorted(self.paths_dir.glob("*.json"))]
|
||||
|
||||
def export_graph(self, *, graph_id: str = "local", events: list[MemoryEvent] | None = None) -> MemoryGraph:
|
||||
return MemoryGraph(
|
||||
graph_id=graph_id,
|
||||
nodes=tuple(self.list_nodes()),
|
||||
edges=tuple(self.list_edges()),
|
||||
events=tuple(events or ()),
|
||||
metadata={"store_schema_version": LOCAL_STORE_SCHEMA, "store_path": str(self.root)},
|
||||
)
|
||||
|
||||
def repair_diagnostics(self, *, events: list[MemoryEvent] | None = None) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
node_ids = {node.node_id for node in self.list_nodes()}
|
||||
event_ids = {event.event_id for event in events or ()}
|
||||
for edge in self.list_edges():
|
||||
if edge.source not in node_ids:
|
||||
diagnostics.append(Diagnostic("error", "missing_edge_source", "Edge source does not reference a node.", edge.edge_id, {"source": edge.source}))
|
||||
if edge.target not in node_ids:
|
||||
diagnostics.append(Diagnostic("error", "missing_edge_target", "Edge target does not reference a node.", edge.edge_id, {"target": edge.target}))
|
||||
for path in self.list_paths():
|
||||
for event_id in path.event_ids:
|
||||
if event_id not in event_ids:
|
||||
diagnostics.append(Diagnostic("warn", "orphaned_path_event", "Path references an event not present in the event log.", path.path_id, {"event_id": event_id}))
|
||||
return tuple(diagnostics)
|
||||
|
||||
def _ensure_layout(self) -> None:
|
||||
for directory in (self.root, self.profiles_dir, self.nodes_dir, self.edges_dir, self.paths_dir, self.activations_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
metadata_path = self.root / "phase-memory.json"
|
||||
if not metadata_path.exists():
|
||||
_write_json(metadata_path, {"schema_version": LOCAL_STORE_SCHEMA})
|
||||
|
||||
|
||||
class JsonlMemoryEventLog:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.touch(exist_ok=True)
|
||||
|
||||
def append(self, event: MemoryEvent) -> MemoryEvent:
|
||||
if any(existing.event_id == event.event_id for existing in self.list_events()):
|
||||
raise ValueError(f"Duplicate memory event id: {event.event_id}")
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event.to_dict(), sort_keys=True, separators=(",", ":")) + "\n")
|
||||
return event
|
||||
|
||||
def list_events(self, *, kind: str | None = None) -> list[MemoryEvent]:
|
||||
events: list[MemoryEvent] = []
|
||||
for data in self._iter_valid_event_dicts():
|
||||
event = MemoryEvent.from_mapping(data)
|
||||
if kind is None or event.kind == kind:
|
||||
events.append(event)
|
||||
return sorted(events, key=lambda event: (event.timestamp, event.event_id))
|
||||
|
||||
def replay_graph(self, store: FileBackedMemoryGraphStore, *, graph_id: str = "local") -> MemoryGraph:
|
||||
return store.export_graph(graph_id=graph_id, events=self.list_events())
|
||||
|
||||
def diagnostics(self) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
seen: set[str] = set()
|
||||
for line_number, raw in self._iter_lines():
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
diagnostics.append(Diagnostic("error", "malformed_event_log_line", "Event log line is not valid JSON.", f"line:{line_number}", {"error": str(exc)}))
|
||||
continue
|
||||
schema = data.get("schema_version")
|
||||
if schema and schema != "markitect.memory.event.v1":
|
||||
diagnostics.append(Diagnostic("warn", "unknown_event_schema", "Event declares an unknown schema version.", f"line:{line_number}", {"schema_version": schema}))
|
||||
event_id = str(data.get("id") or data.get("event_id") or "")
|
||||
if event_id in seen:
|
||||
diagnostics.append(Diagnostic("error", "duplicate_event_id", "Event log contains a duplicate event id.", f"line:{line_number}", {"event_id": event_id}))
|
||||
if event_id:
|
||||
seen.add(event_id)
|
||||
return tuple(diagnostics)
|
||||
|
||||
def _iter_valid_event_dicts(self):
|
||||
for _, raw in self._iter_lines():
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
|
||||
def _iter_lines(self):
|
||||
for line_number, raw in enumerate(self.path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
if raw.strip():
|
||||
yield line_number, raw
|
||||
|
||||
|
||||
class NoopContextPackageCompiler:
|
||||
def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -85,3 +243,32 @@ class RecordingAuditSink:
|
||||
stored = dict(event)
|
||||
self.events.append(stored)
|
||||
return {"recorded": True, "index": len(self.events) - 1, "event": stored}
|
||||
|
||||
|
||||
class JsonlAuditSink:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.touch(exist_ok=True)
|
||||
|
||||
def record(self, event: dict[str, Any]) -> dict[str, Any]:
|
||||
stored = dict(event)
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(stored, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
with self.path.open(encoding="utf-8") as handle:
|
||||
index = max(sum(1 for _ in handle) - 1, 0)
|
||||
return {"recorded": True, "index": index, "event": stored}
|
||||
|
||||
|
||||
def _safe_name(identifier: str) -> str:
|
||||
safe = "".join(char if char.isalnum() or char in ("-", "_", ".") else "_" for char in identifier)
|
||||
return safe or "anonymous"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
196
src/phase_memory/cli.py
Normal file
196
src/phase_memory/cli.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Command line interface for phase-memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
from .adapters import FileBackedMemoryGraphStore, JsonlAuditSink, JsonlMemoryEventLog
|
||||
from .runtime import PhaseMemoryRuntime
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
runtime = PhaseMemoryRuntime()
|
||||
envelope = args.func(args, runtime)
|
||||
if args.format == "summary":
|
||||
_print_summary(envelope)
|
||||
else:
|
||||
print(json.dumps(envelope, indent=2, sort_keys=True))
|
||||
return 0 if envelope.get("valid") else 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="phase-memory")
|
||||
parser.set_defaults(func=_missing_command, format="json")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
profile = subparsers.add_parser("profile", help="Profile operations")
|
||||
profile_subparsers = profile.add_subparsers(dest="profile_command")
|
||||
profile_plan = profile_subparsers.add_parser("plan", help="Plan a Markitect memory profile")
|
||||
profile_plan.add_argument("profile", type=Path)
|
||||
_add_format(profile_plan)
|
||||
profile_plan.set_defaults(func=_profile_plan)
|
||||
|
||||
graph = subparsers.add_parser("graph", help="Graph operations")
|
||||
graph_subparsers = graph.add_subparsers(dest="graph_command")
|
||||
|
||||
lifecycle = graph_subparsers.add_parser("lifecycle", help="Plan graph lifecycle actions")
|
||||
lifecycle.add_argument("graph", type=Path)
|
||||
lifecycle.add_argument("--stale-after-days", type=int)
|
||||
lifecycle.add_argument("--delete-after-days", type=int)
|
||||
lifecycle.add_argument("--refresh-digest", action="append", default=[], metavar="NODE_ID=DIGEST")
|
||||
lifecycle.add_argument("--compact-node", action="append", default=[])
|
||||
_add_format(lifecycle)
|
||||
lifecycle.set_defaults(func=_graph_lifecycle)
|
||||
|
||||
activate = graph_subparsers.add_parser("activate", help="Plan graph activation selection")
|
||||
activate.add_argument("graph", type=Path)
|
||||
activate.add_argument("--max-items", type=int, required=True)
|
||||
activate.add_argument("--max-tokens", type=int, required=True)
|
||||
activate.add_argument("--profile-id")
|
||||
activate.add_argument("--priority-node", action="append", default=[])
|
||||
activate.add_argument("--no-events", action="store_true")
|
||||
_add_format(activate)
|
||||
activate.set_defaults(func=_graph_activate)
|
||||
|
||||
store = subparsers.add_parser("store", help="Local file-backed store operations")
|
||||
store_subparsers = store.add_subparsers(dest="store_command")
|
||||
|
||||
store_import = store_subparsers.add_parser("import", help="Import profile and graph fixtures into a local store")
|
||||
store_import.add_argument("--store", type=Path, required=True)
|
||||
store_import.add_argument("--profile", type=Path)
|
||||
store_import.add_argument("--graph", type=Path)
|
||||
_add_format(store_import)
|
||||
store_import.set_defaults(func=_store_import)
|
||||
|
||||
store_export = store_subparsers.add_parser("export", help="Export a local store as a graph envelope")
|
||||
store_export.add_argument("--store", type=Path, required=True)
|
||||
store_export.add_argument("--graph-id", default="local")
|
||||
_add_format(store_export)
|
||||
store_export.set_defaults(func=_store_export)
|
||||
|
||||
store_repair = store_subparsers.add_parser("repair", help="Report local store repair diagnostics")
|
||||
store_repair.add_argument("--store", type=Path, required=True)
|
||||
_add_format(store_repair)
|
||||
store_repair.set_defaults(func=_store_repair)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _add_format(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--format", choices=("json", "summary"), default="json")
|
||||
|
||||
|
||||
def _profile_plan(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return runtime.plan_profile(_read_json(args.profile), source_ref=str(args.profile))
|
||||
|
||||
|
||||
def _graph_lifecycle(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return runtime.plan_lifecycle(
|
||||
_read_json(args.graph),
|
||||
source_ref=str(args.graph),
|
||||
stale_after_days=args.stale_after_days,
|
||||
delete_after_days=args.delete_after_days,
|
||||
refresh_digests=_parse_digest_args(args.refresh_digest),
|
||||
compact_node_ids=tuple(args.compact_node),
|
||||
)
|
||||
|
||||
|
||||
def _graph_activate(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return runtime.plan_activation(
|
||||
_read_json(args.graph),
|
||||
source_ref=str(args.graph),
|
||||
max_items=args.max_items,
|
||||
max_tokens=args.max_tokens,
|
||||
profile_id=args.profile_id,
|
||||
priority_node_ids=tuple(args.priority_node),
|
||||
include_events=not args.no_events,
|
||||
)
|
||||
|
||||
|
||||
def _store_import(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
runtime = _runtime_for_store(args.store)
|
||||
results = []
|
||||
if args.profile:
|
||||
results.append(runtime.import_profile(_read_json(args.profile), source_ref=str(args.profile)))
|
||||
if args.graph:
|
||||
results.append(runtime.import_graph(_read_json(args.graph), source_ref=str(args.graph)))
|
||||
return {
|
||||
"schema_version": "phase_memory.store.import.v1",
|
||||
"operation": "store.import",
|
||||
"valid": all(result.get("valid") for result in results),
|
||||
"dry_run": False,
|
||||
"store": str(args.store),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def _store_export(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return _runtime_for_store(args.store).export_graph(graph_id=args.graph_id, source_ref=str(args.store))
|
||||
|
||||
|
||||
def _store_repair(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return _runtime_for_store(args.store).repair_diagnostics(source_ref=str(args.store))
|
||||
|
||||
|
||||
def _missing_command(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
raise SystemExit("Missing command. Use --help for usage.")
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _parse_digest_args(items: list[str]) -> dict[str, str]:
|
||||
digests: dict[str, str] = {}
|
||||
for item in items:
|
||||
if "=" not in item:
|
||||
raise SystemExit(f"Expected --refresh-digest NODE_ID=DIGEST, got: {item}")
|
||||
node_id, digest = item.split("=", 1)
|
||||
digests[node_id] = digest
|
||||
return digests
|
||||
|
||||
|
||||
def _runtime_for_store(path: Path) -> PhaseMemoryRuntime:
|
||||
store = FileBackedMemoryGraphStore(path)
|
||||
return PhaseMemoryRuntime(
|
||||
graph_store=store,
|
||||
event_log=JsonlMemoryEventLog(path / "events.jsonl"),
|
||||
audit_sink=JsonlAuditSink(path / "audit.jsonl"),
|
||||
)
|
||||
|
||||
|
||||
def _print_summary(envelope: dict[str, Any]) -> None:
|
||||
subject = envelope.get("subject", {})
|
||||
print(f"{envelope.get('operation')} {subject.get('kind')}:{subject.get('id')}")
|
||||
print(f"valid={str(envelope.get('valid')).lower()} dry_run={str(envelope.get('dry_run')).lower()}")
|
||||
diagnostics = envelope.get("diagnostics", [])
|
||||
if diagnostics:
|
||||
print(f"diagnostics={len(diagnostics)}")
|
||||
data = envelope.get("data", {})
|
||||
if "plan" in data and data["plan"]:
|
||||
plan = data["plan"]
|
||||
print(f"ready={str(plan.get('ready')).lower()}")
|
||||
print(f"capabilities={','.join(plan.get('capabilities', []))}")
|
||||
if "dry_run_actions" in data:
|
||||
print(f"actions={len(data.get('dry_run_actions', []))}")
|
||||
if envelope.get("operation") == "store.import":
|
||||
print(f"results={len(envelope.get('results', []))}")
|
||||
if data.get("graph"):
|
||||
graph = data["graph"]
|
||||
print(f"nodes={len(graph.get('nodes', []))}")
|
||||
print(f"edges={len(graph.get('edges', []))}")
|
||||
print(f"events={len(graph.get('events', []))}")
|
||||
activation = data.get("activation_plan")
|
||||
if activation:
|
||||
print(f"selected_nodes={len(activation.get('selected_node_ids', []))}")
|
||||
print(f"omitted={len(activation.get('omitted', []))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -53,6 +53,18 @@ class LifecycleActionKind(str, Enum):
|
||||
NO_OP = "no_op"
|
||||
|
||||
|
||||
class MemoryPathState(str, Enum):
|
||||
ACTIVE = "active"
|
||||
MERGED = "merged"
|
||||
ABANDONED = "abandoned"
|
||||
COMPACTED = "compacted"
|
||||
|
||||
|
||||
class ReviewDecision(str, Enum):
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Diagnostic:
|
||||
severity: str
|
||||
@@ -279,6 +291,51 @@ class MemoryEvent:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryPath:
|
||||
path_id: str
|
||||
parent_path_id: str = ""
|
||||
event_ids: tuple[str, ...] = ()
|
||||
state: MemoryPathState = MemoryPathState.ACTIVE
|
||||
merged_into: str = ""
|
||||
abandoned_reason: str = ""
|
||||
compacted_summary_id: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=utc_now_iso)
|
||||
updated_at: str = field(default_factory=utc_now_iso)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: dict[str, Any]) -> "MemoryPath":
|
||||
return cls(
|
||||
path_id=str(data.get("id") or data.get("path_id") or ""),
|
||||
parent_path_id=str(data.get("parent_path_id") or ""),
|
||||
event_ids=tuple(str(item) for item in data.get("event_ids", ())),
|
||||
state=MemoryPathState(str(data.get("state") or MemoryPathState.ACTIVE.value)),
|
||||
merged_into=str(data.get("merged_into") or ""),
|
||||
abandoned_reason=str(data.get("abandoned_reason") or ""),
|
||||
compacted_summary_id=str(data.get("compacted_summary_id") or ""),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
created_at=str(data.get("created_at") or utc_now_iso()),
|
||||
updated_at=str(data.get("updated_at") or utc_now_iso()),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return compact_dict(
|
||||
{
|
||||
"id": self.path_id,
|
||||
"parent_path_id": self.parent_path_id,
|
||||
"event_ids": list(self.event_ids),
|
||||
"state": self.state,
|
||||
"merged_into": self.merged_into,
|
||||
"abandoned_reason": self.abandoned_reason,
|
||||
"compacted_summary_id": self.compacted_summary_id,
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryGraph:
|
||||
graph_id: str
|
||||
@@ -326,6 +383,52 @@ class PolicyDecision:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewRecord:
|
||||
review_id: str
|
||||
reviewed_action_id: str
|
||||
reviewer: str
|
||||
decision: ReviewDecision
|
||||
reason: str = ""
|
||||
obligations: tuple[str, ...] = ()
|
||||
source_digests: dict[str, str] = field(default_factory=dict)
|
||||
timestamp: str = field(default_factory=utc_now_iso)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: dict[str, Any]) -> "ReviewRecord":
|
||||
return cls(
|
||||
review_id=str(data.get("id") or data.get("review_id") or ""),
|
||||
reviewed_action_id=str(data.get("reviewed_action_id") or data.get("action_id") or ""),
|
||||
reviewer=str(data.get("reviewer") or data.get("reviewer_id") or "local"),
|
||||
decision=ReviewDecision(str(data.get("decision") or ReviewDecision.REJECTED.value)),
|
||||
reason=str(data.get("reason") or ""),
|
||||
obligations=tuple(str(item) for item in data.get("obligations", ())),
|
||||
source_digests={str(key): str(value) for key, value in dict(data.get("source_digests") or {}).items()},
|
||||
timestamp=str(data.get("timestamp") or utc_now_iso()),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
)
|
||||
|
||||
@property
|
||||
def approved(self) -> bool:
|
||||
return self.decision == ReviewDecision.APPROVED
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return compact_dict(
|
||||
{
|
||||
"id": self.review_id,
|
||||
"reviewed_action_id": self.reviewed_action_id,
|
||||
"reviewer": self.reviewer,
|
||||
"decision": self.decision,
|
||||
"reason": self.reason,
|
||||
"obligations": list(self.obligations),
|
||||
"source_digests": self.source_digests,
|
||||
"timestamp": self.timestamp,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleAction:
|
||||
action: LifecycleActionKind
|
||||
|
||||
47
src/phase_memory/paths.py
Normal file
47
src/phase_memory/paths.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Structured conversational path helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from .models import MemoryEvent, MemoryPath, MemoryPathState
|
||||
from .utils import stable_digest, utc_now_iso
|
||||
|
||||
|
||||
def create_path(path_id: str, *, event_ids: tuple[str, ...] = (), metadata: dict | None = None) -> MemoryPath:
|
||||
return MemoryPath(path_id=path_id, event_ids=event_ids, metadata=dict(metadata or {}))
|
||||
|
||||
|
||||
def branch_path(parent: MemoryPath, path_id: str, *, event_ids: tuple[str, ...] = ()) -> MemoryPath:
|
||||
return MemoryPath(
|
||||
path_id=path_id,
|
||||
parent_path_id=parent.path_id,
|
||||
event_ids=event_ids,
|
||||
metadata={"branched_from": parent.path_id},
|
||||
)
|
||||
|
||||
|
||||
def merge_path(path: MemoryPath, target_path_id: str) -> MemoryPath:
|
||||
return replace(path, state=MemoryPathState.MERGED, merged_into=target_path_id, updated_at=utc_now_iso())
|
||||
|
||||
|
||||
def abandon_path(path: MemoryPath, reason: str) -> MemoryPath:
|
||||
return replace(path, state=MemoryPathState.ABANDONED, abandoned_reason=reason, updated_at=utc_now_iso())
|
||||
|
||||
|
||||
def compact_path(path: MemoryPath, summary_node_id: str) -> MemoryPath:
|
||||
return replace(path, state=MemoryPathState.COMPACTED, compacted_summary_id=summary_node_id, updated_at=utc_now_iso())
|
||||
|
||||
|
||||
def path_event(path: MemoryPath, kind: str, *, metadata: dict | None = None) -> MemoryEvent:
|
||||
event_id = f"path-event:{stable_digest([path.path_id, kind, path.event_ids, metadata or {}])}"
|
||||
return MemoryEvent(
|
||||
event_id=event_id,
|
||||
kind=kind,
|
||||
metadata={
|
||||
"path_id": path.path_id,
|
||||
"parent_path_id": path.parent_path_id,
|
||||
"path_state": path.state.value,
|
||||
**dict(metadata or {}),
|
||||
},
|
||||
)
|
||||
145
src/phase_memory/policy.py
Normal file
145
src/phase_memory/policy.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Policy, review, redaction, and audit helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from .models import Diagnostic, MemoryNode, PolicyDecision, ReviewDecision, ReviewRecord
|
||||
from .utils import stable_digest, utc_now_iso
|
||||
|
||||
AUDIT_EVENT_SCHEMA = "phase_memory.audit.event.v1"
|
||||
REDACTED = "[REDACTED]"
|
||||
|
||||
|
||||
class MemoryOperation(str, Enum):
|
||||
PROFILE_IMPORT = "profile.import"
|
||||
GRAPH_IMPORT = "graph.import"
|
||||
NODE_READ = "node.read"
|
||||
EVENT_READ = "event.read"
|
||||
PROFILE_PLAN = "profile.plan"
|
||||
LIFECYCLE_PLAN = "graph.lifecycle.plan"
|
||||
ACTIVATION_PLAN = "graph.activation.plan"
|
||||
PACKAGE_COMPILE = "package.compile"
|
||||
LIFECYCLE_APPLY = "lifecycle.apply"
|
||||
STABILIZATION = "memory.stabilize"
|
||||
COMPACTION = "memory.compact"
|
||||
REFRESH = "memory.refresh"
|
||||
DELETE_REQUEST = "memory.delete_request"
|
||||
ARCHIVE = "memory.archive"
|
||||
GRAPH_EXPORT = "graph.export"
|
||||
STORE_REPAIR = "store.repair.diagnostics"
|
||||
|
||||
|
||||
POLICY_OPERATION_POINTS = tuple(operation.value for operation in MemoryOperation)
|
||||
|
||||
|
||||
def make_review_record(
|
||||
*,
|
||||
reviewed_action_id: str,
|
||||
reviewer: str,
|
||||
approved: bool,
|
||||
reason: str = "",
|
||||
obligations: tuple[str, ...] = (),
|
||||
source_digests: dict[str, str] | None = None,
|
||||
) -> ReviewRecord:
|
||||
decision = ReviewDecision.APPROVED if approved else ReviewDecision.REJECTED
|
||||
review_id = f"review:{stable_digest([reviewed_action_id, reviewer, decision.value, reason, source_digests or {}])}"
|
||||
return ReviewRecord(
|
||||
review_id=review_id,
|
||||
reviewed_action_id=reviewed_action_id,
|
||||
reviewer=reviewer,
|
||||
decision=decision,
|
||||
reason=reason,
|
||||
obligations=obligations,
|
||||
source_digests=dict(source_digests or {}),
|
||||
)
|
||||
|
||||
|
||||
def audit_event(
|
||||
*,
|
||||
operation_id: str,
|
||||
operation: str,
|
||||
subject: dict[str, str],
|
||||
policy_decision: PolicyDecision,
|
||||
dry_run: bool,
|
||||
source_ref: str,
|
||||
actor: str = "local",
|
||||
planned_action_id: str = "",
|
||||
profile_id: str = "",
|
||||
graph_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": AUDIT_EVENT_SCHEMA,
|
||||
"operation_id": operation_id,
|
||||
"operation": operation,
|
||||
"subject": dict(subject),
|
||||
"allowed": policy_decision.allowed,
|
||||
"profile_id": profile_id,
|
||||
"graph_id": graph_id,
|
||||
"policy_decision": policy_decision.to_dict(),
|
||||
"dry_run": dry_run,
|
||||
"planned_action_id": planned_action_id,
|
||||
"actor": actor,
|
||||
"timestamp": utc_now_iso(),
|
||||
"source": {"ref": source_ref},
|
||||
}
|
||||
|
||||
|
||||
def evaluate_activation_node(
|
||||
node: MemoryNode,
|
||||
*,
|
||||
required_labels: tuple[str, ...] = (),
|
||||
denied_labels: tuple[str, ...] = (),
|
||||
trust_zone: str = "",
|
||||
secrets_allowed: bool = True,
|
||||
approved_reauthorizations: tuple[str, ...] = (),
|
||||
require_fresh: bool = False,
|
||||
) -> tuple[bool, tuple[str, ...]]:
|
||||
labels = set(_list_value(node.policy.get("labels")) + _list_value(node.metadata.get("labels")))
|
||||
reasons: list[str] = []
|
||||
for label in required_labels:
|
||||
if label not in labels:
|
||||
reasons.append(f"missing_label:{label}")
|
||||
for label in denied_labels:
|
||||
if label in labels:
|
||||
reasons.append(f"denied_label:{label}")
|
||||
node_trust_zone = str(node.policy.get("trust_zone") or node.metadata.get("trust_zone") or "")
|
||||
if trust_zone and node_trust_zone and node_trust_zone != trust_zone:
|
||||
reasons.append(f"trust_zone:{node_trust_zone}")
|
||||
if not secrets_allowed and bool(node.policy.get("secret") or node.metadata.get("secret")):
|
||||
reasons.append("secret_denied")
|
||||
reauthorization = str(node.policy.get("reauthorization") or "")
|
||||
if reauthorization and reauthorization not in approved_reauthorizations:
|
||||
reasons.append(f"reauthorization:{reauthorization}")
|
||||
if require_fresh and str(node.freshness.get("status") or "").lower() == "stale":
|
||||
reasons.append("freshness:stale")
|
||||
return not reasons, tuple(reasons)
|
||||
|
||||
|
||||
def redacted_node(node: MemoryNode, *, reasons: tuple[str, ...]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": node.node_id,
|
||||
"kind": node.kind,
|
||||
"text": REDACTED,
|
||||
"policy": REDACTED,
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
|
||||
def policy_denial_diagnostic(node: MemoryNode, reasons: tuple[str, ...]) -> Diagnostic:
|
||||
return Diagnostic(
|
||||
"warn",
|
||||
"activation_policy_denied",
|
||||
"Memory node was omitted from activation by policy.",
|
||||
node.node_id,
|
||||
{"reasons": list(reasons), "redacted": redacted_node(node, reasons=reasons)},
|
||||
)
|
||||
|
||||
|
||||
def _list_value(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
return [str(item) for item in value]
|
||||
471
src/phase_memory/runtime.py
Normal file
471
src/phase_memory/runtime.py
Normal file
@@ -0,0 +1,471 @@
|
||||
"""Local runtime facade for deterministic phase-memory operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .activation import activation_action, plan_activation
|
||||
from .adapters import (
|
||||
AllowAllPolicyGateway,
|
||||
InMemoryMemoryEventLog,
|
||||
InMemoryMemoryGraphStore,
|
||||
NoopContextPackageCompiler,
|
||||
RecordingAuditSink,
|
||||
)
|
||||
from .contracts import ContractIngressResult, graph_from_markitect, profile_from_markitect
|
||||
from .lifecycle import plan_compaction, plan_refresh, plan_retention
|
||||
from .models import (
|
||||
Diagnostic,
|
||||
LifecycleAction,
|
||||
LifecycleActionKind,
|
||||
LifecycleState,
|
||||
MemoryGraph,
|
||||
MemoryNode,
|
||||
MemoryPhase,
|
||||
PolicyDecision,
|
||||
ProfileIntent,
|
||||
ReviewRecord,
|
||||
)
|
||||
from .planner import plan_profile_execution
|
||||
from .policy import audit_event, evaluate_activation_node, make_review_record, policy_denial_diagnostic, redacted_node
|
||||
from .ports import AuditSink, ContextPackageCompiler, MemoryEventLog, MemoryGraphStore, PolicyGateway
|
||||
from .utils import compact_dict, stable_digest, to_plain
|
||||
|
||||
RUNTIME_ENVELOPE_SCHEMA = "phase_memory.runtime.envelope.v1"
|
||||
PACKAGE_REQUEST_SCHEMA = "phase_memory.package_request.v1"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseMemoryRuntime:
|
||||
"""Dependency-light local runtime facade.
|
||||
|
||||
The facade coordinates existing pure planners and local adapters while
|
||||
keeping every public operation dry-run and JSON-serializable by default.
|
||||
"""
|
||||
|
||||
graph_store: MemoryGraphStore = field(default_factory=InMemoryMemoryGraphStore)
|
||||
event_log: MemoryEventLog = field(default_factory=InMemoryMemoryEventLog)
|
||||
package_compiler: ContextPackageCompiler = field(default_factory=NoopContextPackageCompiler)
|
||||
policy_gateway: PolicyGateway = field(default_factory=AllowAllPolicyGateway)
|
||||
audit_sink: AuditSink = field(default_factory=RecordingAuditSink)
|
||||
|
||||
def import_profile(self, data: dict[str, Any], *, source_ref: str = "mapping") -> dict[str, Any]:
|
||||
result = profile_from_markitect(data)
|
||||
if result.valid:
|
||||
self.graph_store.save_profile(result.value)
|
||||
return self._contract_envelope("profile.import", result, source_ref=source_ref)
|
||||
|
||||
def import_graph(self, data: dict[str, Any], *, source_ref: str = "mapping") -> dict[str, Any]:
|
||||
result = graph_from_markitect(data)
|
||||
if result.valid:
|
||||
graph: MemoryGraph = result.value
|
||||
for node in graph.nodes:
|
||||
self.graph_store.save_node(node)
|
||||
for edge in graph.edges:
|
||||
self.graph_store.save_edge(edge)
|
||||
for event in graph.events:
|
||||
self.event_log.append(event)
|
||||
return self._contract_envelope("graph.import", result, source_ref=source_ref)
|
||||
|
||||
def plan_profile(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
available_adapters: Iterable[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = profile_from_markitect(data)
|
||||
profile: ProfileIntent | None = result.value if result.valid else None
|
||||
plan = plan_profile_execution(profile, available_adapters=available_adapters) if profile else None
|
||||
diagnostics = list(result.diagnostics)
|
||||
if plan is not None:
|
||||
diagnostics.extend(plan.diagnostics)
|
||||
return self._envelope(
|
||||
"profile.plan",
|
||||
subject_kind="memory_profile",
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid and plan is not None,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"profile": result.value.to_dict() if hasattr(result.value, "to_dict") else result.value,
|
||||
"plan": plan.to_dict() if plan else None,
|
||||
},
|
||||
)
|
||||
|
||||
def plan_lifecycle(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
stale_after_days: int | None = None,
|
||||
delete_after_days: int | None = None,
|
||||
refresh_digests: dict[str, str] | None = None,
|
||||
compact_node_ids: tuple[str, ...] = (),
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = graph_from_markitect(data)
|
||||
graph: MemoryGraph | None = result.value if result.valid else None
|
||||
actions: list[LifecycleAction] = []
|
||||
|
||||
if graph is not None:
|
||||
actions.extend(
|
||||
plan_retention(
|
||||
graph.nodes,
|
||||
stale_after_days=stale_after_days,
|
||||
delete_after_days=delete_after_days,
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
if refresh_digests:
|
||||
actions.extend(plan_refresh(graph.nodes, source_digest_by_node_id=refresh_digests))
|
||||
if compact_node_ids:
|
||||
by_id = graph.node_by_id()
|
||||
compact_nodes = [by_id[node_id] for node_id in compact_node_ids if node_id in by_id]
|
||||
if compact_nodes:
|
||||
actions.append(plan_compaction(compact_nodes))
|
||||
|
||||
return self._envelope(
|
||||
"graph.lifecycle.plan",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid,
|
||||
diagnostics=result.diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"graph_id": result.subject_id,
|
||||
"dry_run_actions": [action.to_dict() for action in actions],
|
||||
"parameters": compact_dict(
|
||||
{
|
||||
"stale_after_days": stale_after_days,
|
||||
"delete_after_days": delete_after_days,
|
||||
"refresh_digests": refresh_digests or {},
|
||||
"compact_node_ids": list(compact_node_ids),
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def plan_activation(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
max_items: int,
|
||||
max_tokens: int,
|
||||
profile_id: str | None = None,
|
||||
priority_node_ids: tuple[str, ...] = (),
|
||||
include_events: bool = True,
|
||||
policy_context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = graph_from_markitect(data)
|
||||
graph: MemoryGraph | None = result.value if result.valid else None
|
||||
policy_denials: list[dict[str, Any]] = []
|
||||
diagnostics = list(result.diagnostics)
|
||||
if graph is not None and policy_context:
|
||||
graph, policy_denials, policy_diagnostics = _policy_filtered_graph(graph, policy_context)
|
||||
diagnostics.extend(policy_diagnostics)
|
||||
plan = (
|
||||
plan_activation(
|
||||
graph,
|
||||
max_items=max_items,
|
||||
max_tokens=max_tokens,
|
||||
profile_id=profile_id,
|
||||
priority_node_ids=priority_node_ids,
|
||||
include_events=include_events,
|
||||
)
|
||||
if graph
|
||||
else None
|
||||
)
|
||||
if plan is not None:
|
||||
diagnostics.extend(plan.diagnostics)
|
||||
|
||||
return self._envelope(
|
||||
"graph.activation.plan",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid and plan is not None,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"activation_plan": plan.to_dict() if plan else None,
|
||||
"activation_action": activation_action(plan).to_dict() if plan else None,
|
||||
"package_request": self.package_request(plan.selection) if plan else None,
|
||||
"policy_denials": policy_denials,
|
||||
},
|
||||
)
|
||||
|
||||
def compile_package(self, selection: dict[str, Any], *, source_ref: str = "selection") -> dict[str, Any]:
|
||||
request = self.package_request(selection)
|
||||
response = self.package_compiler.compile_selection(selection)
|
||||
return self._envelope(
|
||||
"package.compile",
|
||||
subject_kind="memory_selection",
|
||||
subject_id=str(selection.get("id") or ""),
|
||||
valid=True,
|
||||
diagnostics=(),
|
||||
source_ref=source_ref,
|
||||
data={"package_request": request, "package_response": response},
|
||||
)
|
||||
|
||||
def export_graph(self, *, graph_id: str = "local", source_ref: str = "local-store") -> dict[str, Any]:
|
||||
events = self.event_log.list_events()
|
||||
if hasattr(self.graph_store, "export_graph"):
|
||||
graph = self.graph_store.export_graph(graph_id=graph_id, events=events)
|
||||
else:
|
||||
graph = MemoryGraph(graph_id=graph_id, nodes=tuple(self.graph_store.list_nodes()), events=tuple(events))
|
||||
return self._envelope(
|
||||
"graph.export",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=graph.graph_id,
|
||||
valid=True,
|
||||
diagnostics=(),
|
||||
source_ref=source_ref,
|
||||
data={"graph": graph.to_dict()},
|
||||
)
|
||||
|
||||
def repair_diagnostics(self, *, source_ref: str = "local-store") -> dict[str, Any]:
|
||||
event_diagnostics = self.event_log.diagnostics() if hasattr(self.event_log, "diagnostics") else ()
|
||||
events = self.event_log.list_events()
|
||||
store_diagnostics = (
|
||||
self.graph_store.repair_diagnostics(events=events)
|
||||
if hasattr(self.graph_store, "repair_diagnostics")
|
||||
else ()
|
||||
)
|
||||
diagnostics = tuple(event_diagnostics) + tuple(store_diagnostics)
|
||||
return self._envelope(
|
||||
"store.repair.diagnostics",
|
||||
subject_kind="local_store",
|
||||
subject_id=source_ref,
|
||||
valid=not any(diagnostic.severity == "error" for diagnostic in diagnostics),
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={"diagnostic_count": len(diagnostics)},
|
||||
)
|
||||
|
||||
def apply_lifecycle_actions(
|
||||
self,
|
||||
actions: Iterable[LifecycleAction | dict[str, Any]],
|
||||
*,
|
||||
approval_marker: str = "",
|
||||
review_record: ReviewRecord | dict[str, Any] | None = None,
|
||||
source_ref: str = "lifecycle-actions",
|
||||
) -> dict[str, Any]:
|
||||
applied: list[dict[str, Any]] = []
|
||||
denied: list[dict[str, Any]] = []
|
||||
diagnostics: list[Diagnostic] = []
|
||||
|
||||
for raw_action in actions:
|
||||
action = _coerce_action(raw_action)
|
||||
review = _coerce_review(review_record, action=action, approval_marker=approval_marker)
|
||||
if action.requires_review and (
|
||||
review is None or not review.approved or review.reviewed_action_id != _action_review_id(action)
|
||||
):
|
||||
denied.append(
|
||||
{
|
||||
"target_id": action.target_id,
|
||||
"action": action.action.value,
|
||||
"reason": "review_required",
|
||||
}
|
||||
)
|
||||
diagnostics.append(
|
||||
Diagnostic(
|
||||
"warn",
|
||||
"review_required",
|
||||
"Lifecycle action requires an approval marker before apply.",
|
||||
action.target_id,
|
||||
{"action": action.action.value},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
applied.append(self._apply_action(action, approval_marker=approval_marker or (review.review_id if review else "")))
|
||||
|
||||
return self._envelope(
|
||||
"lifecycle.apply",
|
||||
subject_kind="lifecycle_actions",
|
||||
subject_id=f"batch:{stable_digest([applied, denied])}",
|
||||
valid=not denied,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
dry_run=False,
|
||||
data={
|
||||
"applied": applied,
|
||||
"denied": denied,
|
||||
"approval_marker": approval_marker,
|
||||
"review_record": review_record.to_dict() if isinstance(review_record, ReviewRecord) else review_record,
|
||||
},
|
||||
)
|
||||
|
||||
def package_request(self, selection: dict[str, Any]) -> dict[str, Any]:
|
||||
request_id = f"package-request:{stable_digest(selection)}"
|
||||
return {
|
||||
"schema_version": PACKAGE_REQUEST_SCHEMA,
|
||||
"id": request_id,
|
||||
"selection": dict(selection),
|
||||
"compiler": self.package_compiler.__class__.__name__,
|
||||
"dry_run": True,
|
||||
}
|
||||
|
||||
def _contract_envelope(
|
||||
self,
|
||||
operation: str,
|
||||
result: ContractIngressResult,
|
||||
*,
|
||||
source_ref: str,
|
||||
) -> dict[str, Any]:
|
||||
return self._envelope(
|
||||
operation,
|
||||
subject_kind=result.subject_kind,
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid,
|
||||
diagnostics=result.diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={"value": result.value.to_dict() if hasattr(result.value, "to_dict") else result.value},
|
||||
)
|
||||
|
||||
def _envelope(
|
||||
self,
|
||||
operation: str,
|
||||
*,
|
||||
subject_kind: str,
|
||||
subject_id: str,
|
||||
valid: bool,
|
||||
diagnostics: Iterable[Diagnostic],
|
||||
source_ref: str,
|
||||
data: dict[str, Any],
|
||||
dry_run: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
policy = self.policy_gateway.authorize(
|
||||
action=operation,
|
||||
resource=f"{subject_kind}:{subject_id}",
|
||||
context={"source_ref": source_ref, "dry_run": dry_run},
|
||||
)
|
||||
operation_id = f"op:{stable_digest([operation, subject_kind, subject_id, source_ref, data])}"
|
||||
audit = self.audit_sink.record(
|
||||
audit_event(
|
||||
operation_id=operation_id,
|
||||
operation=operation,
|
||||
subject={"kind": subject_kind, "id": subject_id},
|
||||
policy_decision=policy,
|
||||
dry_run=dry_run,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"schema_version": RUNTIME_ENVELOPE_SCHEMA,
|
||||
"operation_id": operation_id,
|
||||
"operation": operation,
|
||||
"dry_run": dry_run,
|
||||
"valid": valid and policy.allowed,
|
||||
"subject": {"kind": subject_kind, "id": subject_id},
|
||||
"source": {"ref": source_ref},
|
||||
"policy_decision": _policy_to_dict(policy),
|
||||
"audit_receipt": audit,
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics],
|
||||
"data": data,
|
||||
}
|
||||
|
||||
def _apply_action(self, action: LifecycleAction, *, approval_marker: str) -> dict[str, Any]:
|
||||
if action.action == LifecycleActionKind.COMPACT:
|
||||
summary = MemoryNode(
|
||||
action.target_id,
|
||||
"summary",
|
||||
str(action.metadata.get("summary_text") or "Compacted memory summary."),
|
||||
phase=MemoryPhase.STABILIZED,
|
||||
metadata={
|
||||
"source_node_ids": list(action.metadata.get("source_node_ids", ())),
|
||||
"approval_marker": approval_marker,
|
||||
},
|
||||
)
|
||||
self.graph_store.save_node(summary)
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": True}
|
||||
|
||||
if action.to_state is not None:
|
||||
try:
|
||||
node = self.graph_store.get_node(action.target_id)
|
||||
except (KeyError, FileNotFoundError):
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": False, "reason": "missing_node"}
|
||||
self.graph_store.save_node(
|
||||
replace(
|
||||
node,
|
||||
lifecycle=action.to_state,
|
||||
metadata={**dict(node.metadata), "last_lifecycle_action": action.action.value, "approval_marker": approval_marker},
|
||||
)
|
||||
)
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": True}
|
||||
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": False, "reason": "no_state_change"}
|
||||
|
||||
|
||||
def _policy_to_dict(decision: PolicyDecision) -> dict[str, Any]:
|
||||
return decision.to_dict() if hasattr(decision, "to_dict") else to_plain(decision)
|
||||
|
||||
|
||||
def _coerce_action(data: LifecycleAction | dict[str, Any]) -> LifecycleAction:
|
||||
if isinstance(data, LifecycleAction):
|
||||
return data
|
||||
return LifecycleAction(
|
||||
action=LifecycleActionKind(str(data["action"])),
|
||||
target_id=str(data["target_id"]),
|
||||
from_state=LifecycleState(str(data["from_state"])) if data.get("from_state") else None,
|
||||
to_state=LifecycleState(str(data["to_state"])) if data.get("to_state") else None,
|
||||
reason=str(data.get("reason") or ""),
|
||||
requires_review=bool(data.get("requires_review")),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
)
|
||||
|
||||
|
||||
def _action_review_id(action: LifecycleAction) -> str:
|
||||
return f"action:{stable_digest(action.to_dict())}"
|
||||
|
||||
|
||||
def _coerce_review(
|
||||
data: ReviewRecord | dict[str, Any] | None,
|
||||
*,
|
||||
action: LifecycleAction,
|
||||
approval_marker: str,
|
||||
) -> ReviewRecord | None:
|
||||
if isinstance(data, ReviewRecord):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return ReviewRecord.from_mapping(data)
|
||||
if approval_marker:
|
||||
return make_review_record(
|
||||
reviewed_action_id=_action_review_id(action),
|
||||
reviewer="local",
|
||||
approved=True,
|
||||
reason=approval_marker,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _policy_filtered_graph(
|
||||
graph: MemoryGraph,
|
||||
policy_context: dict[str, Any],
|
||||
) -> tuple[MemoryGraph, list[dict[str, Any]], list[Diagnostic]]:
|
||||
allowed_nodes = []
|
||||
denials: list[dict[str, Any]] = []
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for node in graph.nodes:
|
||||
allowed, reasons = evaluate_activation_node(
|
||||
node,
|
||||
required_labels=tuple(policy_context.get("required_labels", ())),
|
||||
denied_labels=tuple(policy_context.get("denied_labels", ())),
|
||||
trust_zone=str(policy_context.get("trust_zone") or ""),
|
||||
secrets_allowed=bool(policy_context.get("secrets_allowed", True)),
|
||||
approved_reauthorizations=tuple(policy_context.get("approved_reauthorizations", ())),
|
||||
require_fresh=bool(policy_context.get("require_fresh", False)),
|
||||
)
|
||||
if allowed:
|
||||
allowed_nodes.append(node)
|
||||
continue
|
||||
denials.append(redacted_node(node, reasons=reasons))
|
||||
diagnostics.append(policy_denial_diagnostic(node, reasons))
|
||||
|
||||
allowed_ids = {node.node_id for node in allowed_nodes}
|
||||
filtered_edges = tuple(edge for edge in graph.edges if edge.source in allowed_ids and edge.target in allowed_ids)
|
||||
return replace(graph, nodes=tuple(allowed_nodes), edges=filtered_edges), denials, diagnostics
|
||||
29
tests/fixtures/runtime-activation-plan-snapshot.json
vendored
Normal file
29
tests/fixtures/runtime-activation-plan-snapshot.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": {
|
||||
"omitted": [
|
||||
"artifact.profile:max_tokens",
|
||||
"event.restart:max_tokens",
|
||||
"risk.durable-write:max_tokens"
|
||||
],
|
||||
"package_request_id": "package-request:854b8a9e0f9a",
|
||||
"plan_id": "activation:6e0ba40234cd",
|
||||
"selected_event_ids": [
|
||||
"event.activation"
|
||||
],
|
||||
"selected_node_ids": [
|
||||
"decision.boundary"
|
||||
],
|
||||
"token_estimate": 9
|
||||
},
|
||||
"diagnostic_codes": [
|
||||
"activation_omitted_items"
|
||||
],
|
||||
"operation": "graph.activation.plan",
|
||||
"operation_id": "op:826d3b06fa5b",
|
||||
"schema_version": "phase_memory.runtime.envelope.v1",
|
||||
"subject": {
|
||||
"id": "phase-memory-fixture-graph",
|
||||
"kind": "memory_graph"
|
||||
},
|
||||
"valid": true
|
||||
}
|
||||
33
tests/fixtures/runtime-profile-plan-snapshot.json
vendored
Normal file
33
tests/fixtures/runtime-profile-plan-snapshot.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"data": {
|
||||
"capabilities": [
|
||||
"activation.plan",
|
||||
"compaction.plan",
|
||||
"policy.gate",
|
||||
"profile.inspect",
|
||||
"profile.plan",
|
||||
"refresh.plan",
|
||||
"retention.plan"
|
||||
],
|
||||
"policy_gates": [
|
||||
"label:project-local",
|
||||
"durable_writes:review-gated",
|
||||
"secrets:denied"
|
||||
],
|
||||
"ready": true,
|
||||
"required_adapters": [
|
||||
"local-event-log",
|
||||
"local-graph-store",
|
||||
"markitect-context-package"
|
||||
]
|
||||
},
|
||||
"diagnostic_codes": [],
|
||||
"operation": "profile.plan",
|
||||
"operation_id": "op:0277ca92fd86",
|
||||
"schema_version": "phase_memory.runtime.envelope.v1",
|
||||
"subject": {
|
||||
"id": "phase-memory-fixture-profile",
|
||||
"kind": "memory_profile"
|
||||
},
|
||||
"valid": true
|
||||
}
|
||||
112
tests/test_cli.py
Normal file
112
tests/test_cli.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from phase_memory.cli import main
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_pyproject_exposes_phase_memory_console_script() -> None:
|
||||
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
|
||||
assert pyproject["project"]["scripts"]["phase-memory"] == "phase_memory.cli:main"
|
||||
|
||||
|
||||
def test_cli_profile_plan_emits_json(capsys) -> None:
|
||||
code = main(["profile", "plan", str(FIXTURES / "memory-profile.json")])
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert code == 0
|
||||
assert output["operation"] == "profile.plan"
|
||||
assert output["data"]["plan"]["ready"] is True
|
||||
|
||||
|
||||
def test_cli_graph_lifecycle_emits_dry_run_actions(capsys) -> None:
|
||||
code = main(
|
||||
[
|
||||
"graph",
|
||||
"lifecycle",
|
||||
str(FIXTURES / "memory-graph.json"),
|
||||
"--stale-after-days",
|
||||
"7",
|
||||
"--delete-after-days",
|
||||
"30",
|
||||
"--refresh-digest",
|
||||
"event.restart=new",
|
||||
"--compact-node",
|
||||
"event.restart",
|
||||
]
|
||||
)
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert code == 0
|
||||
assert output["operation"] == "graph.lifecycle.plan"
|
||||
assert [action["action"] for action in output["data"]["dry_run_actions"]][:2] == ["mark_stale", "refresh"]
|
||||
|
||||
|
||||
def test_cli_graph_activate_emits_selection(capsys) -> None:
|
||||
code = main(
|
||||
[
|
||||
"graph",
|
||||
"activate",
|
||||
str(FIXTURES / "memory-graph.json"),
|
||||
"--max-items",
|
||||
"2",
|
||||
"--max-tokens",
|
||||
"18",
|
||||
"--profile-id",
|
||||
"phase-memory-fixture-profile",
|
||||
"--priority-node",
|
||||
"decision.boundary",
|
||||
]
|
||||
)
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert code == 0
|
||||
assert output["operation"] == "graph.activation.plan"
|
||||
assert output["data"]["activation_plan"]["selection"]["profile"] == "phase-memory-fixture-profile"
|
||||
|
||||
|
||||
def test_cli_summary_format_is_concise(capsys) -> None:
|
||||
code = main(["profile", "plan", str(FIXTURES / "memory-profile.json"), "--format", "summary"])
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert "profile.plan memory_profile:phase-memory-fixture-profile" in output
|
||||
assert "ready=true" in output
|
||||
|
||||
|
||||
def test_cli_store_import_export_and_repair(tmp_path, capsys) -> None:
|
||||
store = tmp_path / "memory-store"
|
||||
|
||||
import_code = main(
|
||||
[
|
||||
"store",
|
||||
"import",
|
||||
"--store",
|
||||
str(store),
|
||||
"--profile",
|
||||
str(FIXTURES / "memory-profile.json"),
|
||||
"--graph",
|
||||
str(FIXTURES / "memory-graph.json"),
|
||||
]
|
||||
)
|
||||
imported = json.loads(capsys.readouterr().out)
|
||||
|
||||
export_code = main(["store", "export", "--store", str(store), "--graph-id", "cli-export"])
|
||||
exported = json.loads(capsys.readouterr().out)
|
||||
|
||||
repair_code = main(["store", "repair", "--store", str(store)])
|
||||
repair = json.loads(capsys.readouterr().out)
|
||||
|
||||
assert import_code == 0
|
||||
assert imported["operation"] == "store.import"
|
||||
assert (store / "phase-memory.json").exists()
|
||||
assert export_code == 0
|
||||
assert exported["data"]["graph"]["id"] == "cli-export"
|
||||
assert len(exported["data"]["graph"]["nodes"]) == 4
|
||||
assert repair_code == 0
|
||||
assert repair["data"]["diagnostic_count"] == 0
|
||||
122
tests/test_file_backed_runtime.py
Normal file
122
tests/test_file_backed_runtime.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from phase_memory.adapters import FileBackedMemoryGraphStore, JsonlAuditSink, JsonlMemoryEventLog
|
||||
from phase_memory.lifecycle import plan_compaction, plan_retention
|
||||
from phase_memory.models import LifecycleAction, LifecycleActionKind, LifecycleState, MemoryEdge, MemoryEvent, MemoryNode
|
||||
from phase_memory.paths import abandon_path, branch_path, create_path, merge_path, path_event
|
||||
from phase_memory.runtime import PhaseMemoryRuntime
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_file_backed_store_round_trips_and_exports_graph(tmp_path) -> None:
|
||||
store = FileBackedMemoryGraphStore(tmp_path)
|
||||
event_log = JsonlMemoryEventLog(tmp_path / "events.jsonl")
|
||||
runtime = PhaseMemoryRuntime(graph_store=store, event_log=event_log, audit_sink=JsonlAuditSink(tmp_path / "audit.jsonl"))
|
||||
|
||||
runtime.import_profile(_load("memory-profile.json"), source_ref="profile")
|
||||
runtime.import_graph(_load("memory-graph.json"), source_ref="graph")
|
||||
|
||||
assert store.get_profile("phase-memory-fixture-profile").profile_id == "phase-memory-fixture-profile"
|
||||
assert store.get_node("decision.boundary").kind == "decision"
|
||||
assert store.list_edges(source="decision.boundary")[0].target == "artifact.profile"
|
||||
|
||||
exported = runtime.export_graph(graph_id="exported")["data"]["graph"]
|
||||
assert exported["schema_version"] == "markitect.memory.graph.v1"
|
||||
assert exported["id"] == "exported"
|
||||
assert len(exported["nodes"]) == 4
|
||||
assert len(exported["edges"]) == 2
|
||||
assert len(exported["events"]) == 1
|
||||
|
||||
|
||||
def test_jsonl_event_log_detects_duplicates_and_corruption(tmp_path) -> None:
|
||||
log = JsonlMemoryEventLog(tmp_path / "events.jsonl")
|
||||
event = MemoryEvent("event.a", "recorded", timestamp="2026-05-18T00:00:00+00:00")
|
||||
|
||||
log.append(event)
|
||||
|
||||
try:
|
||||
log.append(event)
|
||||
except ValueError as exc:
|
||||
assert "Duplicate memory event id" in str(exc)
|
||||
else:
|
||||
raise AssertionError("duplicate event id should fail")
|
||||
|
||||
with (tmp_path / "events.jsonl").open("a", encoding="utf-8") as handle:
|
||||
handle.write("{not-json}\n")
|
||||
handle.write(json.dumps({"schema_version": "unknown.event.v9", "id": "event.b", "kind": "recorded"}) + "\n")
|
||||
|
||||
diagnostics = log.diagnostics()
|
||||
assert [diagnostic.code for diagnostic in diagnostics] == ["malformed_event_log_line", "unknown_event_schema"]
|
||||
assert log.list_events(kind="recorded")[0].event_id == "event.a"
|
||||
|
||||
|
||||
def test_memory_paths_model_branch_merge_and_abandon_as_events(tmp_path) -> None:
|
||||
store = FileBackedMemoryGraphStore(tmp_path)
|
||||
root = create_path("path.root", event_ids=("event.root",))
|
||||
branch = branch_path(root, "path.branch", event_ids=("event.branch",))
|
||||
merged = merge_path(branch, "path.root")
|
||||
abandoned = abandon_path(branch, "superseded by main path")
|
||||
|
||||
store.save_path(root)
|
||||
store.save_path(merged)
|
||||
|
||||
assert store.get_path("path.branch").merged_into == "path.root"
|
||||
assert path_event(merged, "path.merged").metadata["path_state"] == "merged"
|
||||
assert abandoned.abandoned_reason == "superseded by main path"
|
||||
|
||||
|
||||
def test_repair_diagnostics_report_missing_edges_and_orphaned_path_events(tmp_path) -> None:
|
||||
store = FileBackedMemoryGraphStore(tmp_path)
|
||||
log = JsonlMemoryEventLog(tmp_path / "events.jsonl")
|
||||
runtime = PhaseMemoryRuntime(graph_store=store, event_log=log)
|
||||
|
||||
store.save_node(MemoryNode("node.a", "decision"))
|
||||
store.save_edge(MemoryEdge("edge.bad", "depends_on", "node.a", "missing"))
|
||||
store.save_path(create_path("path.root", event_ids=("missing.event",)))
|
||||
|
||||
envelope = runtime.repair_diagnostics(source_ref=str(tmp_path))
|
||||
|
||||
assert envelope["valid"] is False
|
||||
assert [diagnostic["code"] for diagnostic in envelope["diagnostics"]] == ["missing_edge_target", "orphaned_path_event"]
|
||||
|
||||
|
||||
def test_lifecycle_apply_requires_approval_for_reviewable_actions(tmp_path) -> None:
|
||||
store = FileBackedMemoryGraphStore(tmp_path)
|
||||
runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl"))
|
||||
node = store.save_node(MemoryNode("node.a", "episode", "Trace text"))
|
||||
compact = plan_compaction([node])
|
||||
|
||||
denied = runtime.apply_lifecycle_actions([compact])
|
||||
|
||||
assert denied["valid"] is False
|
||||
assert denied["data"]["denied"][0]["reason"] == "review_required"
|
||||
assert [node.node_id for node in store.list_nodes()] == ["node.a"]
|
||||
|
||||
applied = runtime.apply_lifecycle_actions([compact], approval_marker="review:local")
|
||||
|
||||
assert applied["valid"] is True
|
||||
assert store.get_node(compact.target_id).kind == "summary"
|
||||
|
||||
|
||||
def test_lifecycle_apply_can_mark_non_review_actions(tmp_path) -> None:
|
||||
store = FileBackedMemoryGraphStore(tmp_path)
|
||||
runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl"))
|
||||
store.save_node(MemoryNode("stale", "episode", freshness={"updated_at": "2026-05-01T00:00:00+00:00"}))
|
||||
action = LifecycleAction(
|
||||
LifecycleActionKind.MARK_STALE,
|
||||
"stale",
|
||||
from_state=LifecycleState.ACTIVE,
|
||||
to_state=LifecycleState.STALE,
|
||||
)
|
||||
|
||||
envelope = runtime.apply_lifecycle_actions([action])
|
||||
|
||||
assert envelope["valid"] is True
|
||||
assert store.get_node("stale").lifecycle == LifecycleState.STALE
|
||||
105
tests/test_policy_runtime.py
Normal file
105
tests/test_policy_runtime.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from phase_memory.adapters import FileBackedMemoryGraphStore, JsonlMemoryEventLog
|
||||
from phase_memory.lifecycle import plan_compaction
|
||||
from phase_memory.models import MemoryNode, ReviewDecision
|
||||
from phase_memory.policy import AUDIT_EVENT_SCHEMA, POLICY_OPERATION_POINTS, REDACTED, make_review_record
|
||||
from phase_memory.runtime import PhaseMemoryRuntime
|
||||
from phase_memory.utils import stable_digest
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_policy_operation_points_cover_runtime_boundaries() -> None:
|
||||
assert "profile.import" in POLICY_OPERATION_POINTS
|
||||
assert "graph.activation.plan" in POLICY_OPERATION_POINTS
|
||||
assert "lifecycle.apply" in POLICY_OPERATION_POINTS
|
||||
assert "graph.export" in POLICY_OPERATION_POINTS
|
||||
|
||||
|
||||
def test_runtime_audit_events_use_stable_schema() -> None:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
|
||||
envelope = runtime.plan_profile(_load("memory-profile.json"), source_ref="profile")
|
||||
event = envelope["audit_receipt"]["event"]
|
||||
|
||||
assert event["schema_version"] == AUDIT_EVENT_SCHEMA
|
||||
assert event["operation"] == "profile.plan"
|
||||
assert event["allowed"] is True
|
||||
assert event["policy_decision"]["allowed"] is True
|
||||
assert event["source"]["ref"] == "profile"
|
||||
|
||||
|
||||
def test_review_record_approves_review_required_lifecycle_action(tmp_path) -> None:
|
||||
store = FileBackedMemoryGraphStore(tmp_path)
|
||||
runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl"))
|
||||
node = store.save_node(MemoryNode("node.a", "episode", "Trace text"))
|
||||
action = plan_compaction([node])
|
||||
review = make_review_record(
|
||||
reviewed_action_id=f"action:{stable_digest(action.to_dict())}",
|
||||
reviewer="reviewer.local",
|
||||
approved=True,
|
||||
reason="reviewed compacted summary",
|
||||
obligations=("retain_sources",),
|
||||
source_digests={"node.a": "digest"},
|
||||
)
|
||||
|
||||
envelope = runtime.apply_lifecycle_actions([action], review_record=review)
|
||||
|
||||
assert review.decision == ReviewDecision.APPROVED
|
||||
assert envelope["valid"] is True
|
||||
assert envelope["data"]["review_record"]["id"] == review.review_id
|
||||
assert store.get_node(action.target_id).metadata["approval_marker"] == review.review_id
|
||||
|
||||
|
||||
def test_rejected_or_mismatched_review_record_denies_apply(tmp_path) -> None:
|
||||
store = FileBackedMemoryGraphStore(tmp_path)
|
||||
runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl"))
|
||||
node = store.save_node(MemoryNode("node.a", "episode", "Trace text"))
|
||||
action = plan_compaction([node])
|
||||
review = make_review_record(
|
||||
reviewed_action_id="action:other",
|
||||
reviewer="reviewer.local",
|
||||
approved=True,
|
||||
reason="wrong action",
|
||||
)
|
||||
|
||||
envelope = runtime.apply_lifecycle_actions([action], review_record=review)
|
||||
|
||||
assert envelope["valid"] is False
|
||||
assert envelope["data"]["denied"][0]["reason"] == "review_required"
|
||||
|
||||
|
||||
def test_activation_policy_denies_and_redacts_nodes() -> None:
|
||||
graph = _load("memory-graph.json")
|
||||
graph["nodes"][0]["policy"] = {"labels": ["project-local"], "trust_zone": "local"}
|
||||
graph["nodes"][1]["policy"] = {"labels": ["project-local"], "trust_zone": "local", "reauthorization": "daily"}
|
||||
graph["nodes"][2]["policy"] = {"labels": ["project-local"], "secret": True}
|
||||
graph["nodes"][3]["policy"] = {"labels": ["restricted"]}
|
||||
runtime = PhaseMemoryRuntime()
|
||||
|
||||
envelope = runtime.plan_activation(
|
||||
graph,
|
||||
max_items=4,
|
||||
max_tokens=80,
|
||||
policy_context={
|
||||
"required_labels": ["project-local"],
|
||||
"denied_labels": ["restricted"],
|
||||
"trust_zone": "local",
|
||||
"secrets_allowed": False,
|
||||
"approved_reauthorizations": [],
|
||||
},
|
||||
)
|
||||
|
||||
selected = envelope["data"]["activation_plan"]["selected_node_ids"]
|
||||
denials = envelope["data"]["policy_denials"]
|
||||
assert selected == ["decision.boundary"]
|
||||
assert [item["id"] for item in denials] == ["event.restart", "artifact.profile", "risk.durable-write"]
|
||||
assert all(item["text"] == REDACTED for item in denials)
|
||||
assert "activation_policy_denied" in [diagnostic["code"] for diagnostic in envelope["diagnostics"]]
|
||||
82
tests/test_runtime.py
Normal file
82
tests/test_runtime.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from phase_memory.runtime import PACKAGE_REQUEST_SCHEMA, RUNTIME_ENVELOPE_SCHEMA, PhaseMemoryRuntime
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_runtime_profile_plan_envelope_is_json_serializable() -> None:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
|
||||
envelope = runtime.plan_profile(_load("memory-profile.json"), source_ref="tests/fixtures/memory-profile.json")
|
||||
|
||||
assert envelope["schema_version"] == RUNTIME_ENVELOPE_SCHEMA
|
||||
assert envelope["operation"] == "profile.plan"
|
||||
assert envelope["valid"] is True
|
||||
assert envelope["dry_run"] is True
|
||||
assert envelope["subject"] == {"kind": "memory_profile", "id": "phase-memory-fixture-profile"}
|
||||
assert envelope["policy_decision"]["allowed"] is True
|
||||
assert envelope["audit_receipt"]["recorded"] is True
|
||||
assert envelope["data"]["plan"]["ready"] is True
|
||||
assert "activation.plan" in envelope["data"]["plan"]["capabilities"]
|
||||
json.dumps(envelope, sort_keys=True)
|
||||
|
||||
|
||||
def test_runtime_lifecycle_plan_collects_dry_run_actions() -> None:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
|
||||
envelope = runtime.plan_lifecycle(
|
||||
_load("memory-graph.json"),
|
||||
source_ref="tests/fixtures/memory-graph.json",
|
||||
stale_after_days=7,
|
||||
delete_after_days=30,
|
||||
refresh_digests={"event.restart": "new"},
|
||||
compact_node_ids=("event.restart", "risk.durable-write"),
|
||||
now=datetime(2026, 5, 18, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
actions = envelope["data"]["dry_run_actions"]
|
||||
assert envelope["operation"] == "graph.lifecycle.plan"
|
||||
assert [action["action"] for action in actions] == ["mark_stale", "refresh", "compact"]
|
||||
assert all("physical_delete" not in action.get("metadata", {}) or action["metadata"]["physical_delete"] is False for action in actions)
|
||||
json.dumps(envelope, sort_keys=True)
|
||||
|
||||
|
||||
def test_runtime_activation_plan_includes_package_request() -> None:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
|
||||
envelope = runtime.plan_activation(
|
||||
_load("memory-graph.json"),
|
||||
source_ref="tests/fixtures/memory-graph.json",
|
||||
max_items=2,
|
||||
max_tokens=18,
|
||||
profile_id="phase-memory-fixture-profile",
|
||||
priority_node_ids=("decision.boundary",),
|
||||
)
|
||||
|
||||
activation = envelope["data"]["activation_plan"]
|
||||
package_request = envelope["data"]["package_request"]
|
||||
assert envelope["operation"] == "graph.activation.plan"
|
||||
assert activation["selected_node_ids"][0] == "decision.boundary"
|
||||
assert activation["selection"]["schema_version"] == "markitect.memory.selection.v1"
|
||||
assert package_request["schema_version"] == PACKAGE_REQUEST_SCHEMA
|
||||
assert package_request["dry_run"] is True
|
||||
assert package_request["selection"]["id"] == activation["plan_id"]
|
||||
|
||||
|
||||
def test_runtime_compile_package_wraps_compiler_response() -> None:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
selection = {"schema_version": "markitect.memory.selection.v1", "id": "selection.a", "nodes": ["node.a"], "events": []}
|
||||
|
||||
envelope = runtime.compile_package(selection)
|
||||
|
||||
assert envelope["operation"] == "package.compile"
|
||||
assert envelope["data"]["package_request"]["selection"] == selection
|
||||
assert envelope["data"]["package_response"]["package_id"] == "package:selection.a"
|
||||
70
tests/test_runtime_snapshots.py
Normal file
70
tests/test_runtime_snapshots.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from phase_memory.runtime import PhaseMemoryRuntime
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_profile_plan_runtime_snapshot() -> None:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
envelope = runtime.plan_profile(_load("memory-profile.json"), source_ref="tests/fixtures/memory-profile.json")
|
||||
|
||||
assert _profile_projection(envelope) == _load("runtime-profile-plan-snapshot.json")
|
||||
|
||||
|
||||
def test_activation_plan_runtime_snapshot() -> None:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
envelope = runtime.plan_activation(
|
||||
_load("memory-graph.json"),
|
||||
source_ref="tests/fixtures/memory-graph.json",
|
||||
max_items=2,
|
||||
max_tokens=18,
|
||||
profile_id="phase-memory-fixture-profile",
|
||||
priority_node_ids=("decision.boundary",),
|
||||
)
|
||||
|
||||
assert _activation_projection(envelope) == _load("runtime-activation-plan-snapshot.json")
|
||||
|
||||
|
||||
def _profile_projection(envelope: dict) -> dict:
|
||||
plan = envelope["data"]["plan"]
|
||||
return {
|
||||
"schema_version": envelope["schema_version"],
|
||||
"operation": envelope["operation"],
|
||||
"operation_id": envelope["operation_id"],
|
||||
"valid": envelope["valid"],
|
||||
"subject": envelope["subject"],
|
||||
"diagnostic_codes": [diagnostic["code"] for diagnostic in envelope["diagnostics"]],
|
||||
"data": {
|
||||
"ready": plan["ready"],
|
||||
"capabilities": plan["capabilities"],
|
||||
"required_adapters": plan["required_adapters"],
|
||||
"policy_gates": plan["policy_gates"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _activation_projection(envelope: dict) -> dict:
|
||||
plan = envelope["data"]["activation_plan"]
|
||||
return {
|
||||
"schema_version": envelope["schema_version"],
|
||||
"operation": envelope["operation"],
|
||||
"operation_id": envelope["operation_id"],
|
||||
"valid": envelope["valid"],
|
||||
"subject": envelope["subject"],
|
||||
"diagnostic_codes": [diagnostic["code"] for diagnostic in envelope["diagnostics"]],
|
||||
"data": {
|
||||
"plan_id": plan["plan_id"],
|
||||
"selected_node_ids": plan["selected_node_ids"],
|
||||
"selected_event_ids": plan["selected_event_ids"],
|
||||
"omitted": [f"{item['id']}:{item['reason']}" for item in plan["omitted"]],
|
||||
"token_estimate": plan["token_estimate"],
|
||||
"package_request_id": envelope["data"]["package_request"]["id"],
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user