Implement local runtime persistence and policy gates

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

View File

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

View File

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

112
tests/test_cli.py Normal file
View File

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

View File

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

View File

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

82
tests/test_runtime.py Normal file
View File

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

View File

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