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.
|
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
|
## Package Map
|
||||||
|
|
||||||
- `phase_memory.models`: domain records, phases, lifecycle states, diagnostics,
|
- `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.activation`: Markitect-compatible activation selection planning.
|
||||||
- `phase_memory.ports`: runtime port protocols.
|
- `phase_memory.ports`: runtime port protocols.
|
||||||
- `phase_memory.adapters`: deterministic in-memory test adapters.
|
- `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
|
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
|
Durable writes, external adapters, live LLM extraction, vector retrieval, and
|
||||||
service deployment can follow once the plan model is stable.
|
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
|
## Open Questions
|
||||||
|
|
||||||
- Should `phase-memory` depend directly on `markitect-tool` for validation, or
|
- 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 = []
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
phase-memory = "phase_memory.cli:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,19 @@ from .models import (
|
|||||||
MemoryGraph,
|
MemoryGraph,
|
||||||
MemoryKind,
|
MemoryKind,
|
||||||
MemoryNode,
|
MemoryNode,
|
||||||
|
MemoryPath,
|
||||||
|
MemoryPathState,
|
||||||
MemoryPhase,
|
MemoryPhase,
|
||||||
PolicyDecision,
|
PolicyDecision,
|
||||||
ProfileExecutionPlan,
|
ProfileExecutionPlan,
|
||||||
ProfileIntent,
|
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 .planner import plan_profile_execution
|
||||||
|
from .runtime import PhaseMemoryRuntime
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ActivationPlan",
|
"ActivationPlan",
|
||||||
@@ -37,11 +44,24 @@ __all__ = [
|
|||||||
"MemoryGraph",
|
"MemoryGraph",
|
||||||
"MemoryKind",
|
"MemoryKind",
|
||||||
"MemoryNode",
|
"MemoryNode",
|
||||||
|
"MemoryPath",
|
||||||
|
"MemoryPathState",
|
||||||
"MemoryPhase",
|
"MemoryPhase",
|
||||||
"PolicyDecision",
|
"PolicyDecision",
|
||||||
"ProfileExecutionPlan",
|
"ProfileExecutionPlan",
|
||||||
"ProfileIntent",
|
"ProfileIntent",
|
||||||
|
"ReviewDecision",
|
||||||
|
"ReviewRecord",
|
||||||
|
"PhaseMemoryRuntime",
|
||||||
|
"POLICY_OPERATION_POINTS",
|
||||||
|
"MemoryOperation",
|
||||||
|
"abandon_path",
|
||||||
|
"branch_path",
|
||||||
|
"compact_path",
|
||||||
|
"create_path",
|
||||||
"graph_from_markitect",
|
"graph_from_markitect",
|
||||||
|
"merge_path",
|
||||||
|
"make_review_record",
|
||||||
"plan_activation",
|
"plan_activation",
|
||||||
"plan_compaction",
|
"plan_compaction",
|
||||||
"plan_phase_transition",
|
"plan_phase_transition",
|
||||||
@@ -49,6 +69,7 @@ __all__ = [
|
|||||||
"plan_refresh",
|
"plan_refresh",
|
||||||
"plan_retention",
|
"plan_retention",
|
||||||
"profile_from_markitect",
|
"profile_from_markitect",
|
||||||
|
"path_event",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
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:
|
class InMemoryMemoryGraphStore:
|
||||||
@@ -63,6 +67,160 @@ class InMemoryMemoryEventLog:
|
|||||||
return events
|
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:
|
class NoopContextPackageCompiler:
|
||||||
def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]:
|
def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -85,3 +243,32 @@ class RecordingAuditSink:
|
|||||||
stored = dict(event)
|
stored = dict(event)
|
||||||
self.events.append(stored)
|
self.events.append(stored)
|
||||||
return {"recorded": True, "index": len(self.events) - 1, "event": 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"
|
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)
|
@dataclass(frozen=True)
|
||||||
class Diagnostic:
|
class Diagnostic:
|
||||||
severity: str
|
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)
|
@dataclass(frozen=True)
|
||||||
class MemoryGraph:
|
class MemoryGraph:
|
||||||
graph_id: str
|
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)
|
@dataclass(frozen=True)
|
||||||
class LifecycleAction:
|
class LifecycleAction:
|
||||||
action: LifecycleActionKind
|
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
|
## 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
|
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,
|
foundation, a usable local runtime facade, a CLI, a file-backed local
|
||||||
CLI, file-backed persistence, review-gated apply path, package compiler bridge,
|
workspace, and first-slice policy/review/audit gates. It is not yet an
|
||||||
or service contract.
|
interop-complete runtime because richer Markitect package bridge, activation
|
||||||
|
quality, and service contracts remain ahead.
|
||||||
|
|
||||||
| Dimension | Current | Target | Evidence | Needed Next |
|
| 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. |
|
| 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. |
|
| 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.0 | 4.0 | Markitect-compatible profile loading and diagnostics | Add validation adapter boundary and compatibility fixture catalog. |
|
| 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 | 2.0 | 4.0 | Graph loading, edge endpoint diagnostics, event model | Add richer event path modeling and import/export repair diagnostics. |
|
| 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 | 2.5 | 4.0 | Phases, memory kinds, lifecycle states, actions | Add transition rules, approved apply behavior, path-aware state updates. |
|
| 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 | 2.5 | 4.0 | Adapter plan, capabilities, policy gates, fallback behavior | Add runtime orchestration, JSON snapshots, CLI outputs. |
|
| 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 | 2.0 | 4.0 | Transition, retention, refresh, compaction dry-run plans | Add profile-driven rule evaluation and review-gated apply. |
|
| 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.0 | 5.0 | Budgeted selection and Markitect-compatible selection output | Add graph neighborhoods, event paths, ranking, metadata preservation, metrics. |
|
| 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 | 1.0 | 4.0 | In-memory adapters only | Add versioned file-backed graph store and JSONL event log. |
|
| 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 | 1.5 | 5.0 | Policy/audit ports, allow-all gateway, recording sink, review flags | Add enforcement points, review records, redaction, audit schema. |
|
| 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 | 1.5 | 4.0 | Planner diagnostics and observability event names | Add audit/health envelopes and adapter status diagnostics. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## Score Movement Rules
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Local Runtime Facade And CLI"
|
title: "Local Runtime Facade And CLI"
|
||||||
domain: markitect
|
domain: markitect
|
||||||
repo: phase-memory
|
repo: phase-memory
|
||||||
status: proposed
|
status: finished
|
||||||
owner: phase-memory
|
owner: phase-memory
|
||||||
topic_slug: local-runtime
|
topic_slug: local-runtime
|
||||||
planning_priority: P1
|
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 start a long-lived HTTP service in this workplan.
|
||||||
- Do not add live LLM, vector, or graph database dependencies.
|
- 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
|
## T01 - Add a local runtime facade
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0002-T01
|
id: PMEM-WP-0002-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "456557a9-3ac3-483b-bbdd-5591224894b9"
|
state_hub_task_id: "456557a9-3ac3-483b-bbdd-5591224894b9"
|
||||||
```
|
```
|
||||||
@@ -78,7 +101,7 @@ usable, but gives integrations one obvious local entrypoint.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0002-T02
|
id: PMEM-WP-0002-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b04054cb-d743-4fcd-9b37-2685d1f9c00d"
|
state_hub_task_id: "b04054cb-d743-4fcd-9b37-2685d1f9c00d"
|
||||||
```
|
```
|
||||||
@@ -100,7 +123,7 @@ ids, dry-run flags, and source contract references.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0002-T03
|
id: PMEM-WP-0002-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "8463924e-a6ce-43f1-b7fc-544a2aa7fd5f"
|
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
|
```task
|
||||||
id: PMEM-WP-0002-T04
|
id: PMEM-WP-0002-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ab818835-5ef3-4a43-adf2-444ab712ead9"
|
state_hub_task_id: "ab818835-5ef3-4a43-adf2-444ab712ead9"
|
||||||
```
|
```
|
||||||
@@ -142,7 +165,7 @@ refresh, and compaction proposals.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0002-T05
|
id: PMEM-WP-0002-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "9c8e8511-f00a-4685-91b5-a52c93d8461d"
|
state_hub_task_id: "9c8e8511-f00a-4685-91b5-a52c93d8461d"
|
||||||
```
|
```
|
||||||
@@ -161,7 +184,7 @@ Output: activation command, deterministic output tests, and README usage.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0002-T06
|
id: PMEM-WP-0002-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "a005aa31-053e-4b51-b3ea-3bebf24ac833"
|
state_hub_task_id: "a005aa31-053e-4b51-b3ea-3bebf24ac833"
|
||||||
```
|
```
|
||||||
@@ -176,7 +199,7 @@ Output: stable examples for downstream repos and future compatibility checks.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0002-T07
|
id: PMEM-WP-0002-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "f6c6c6b2-141a-44da-a3d5-dbef95559049"
|
state_hub_task_id: "f6c6c6b2-141a-44da-a3d5-dbef95559049"
|
||||||
```
|
```
|
||||||
@@ -200,3 +223,26 @@ five minutes.
|
|||||||
the CLI against existing fixtures.
|
the CLI against existing fixtures.
|
||||||
- Runtime outputs are deterministic and JSON-serializable.
|
- Runtime outputs are deterministic and JSON-serializable.
|
||||||
- No default path mutates durable memory stores.
|
- 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"
|
title: "File-Backed Stores And Event Path Runtime"
|
||||||
domain: markitect
|
domain: markitect
|
||||||
repo: phase-memory
|
repo: phase-memory
|
||||||
status: proposed
|
status: finished
|
||||||
owner: phase-memory
|
owner: phase-memory
|
||||||
topic_slug: local-persistence
|
topic_slug: local-persistence
|
||||||
planning_priority: P1
|
planning_priority: P1
|
||||||
@@ -42,11 +42,36 @@ first-class runtime objects.
|
|||||||
- Do not add hidden background compaction or deletion.
|
- Do not add hidden background compaction or deletion.
|
||||||
- Do not make file-backed storage the only adapter option.
|
- 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
|
## T01 - Define local storage layout
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0003-T01
|
id: PMEM-WP-0003-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "37d082c7-c019-4ecd-8655-94f8f27807ff"
|
state_hub_task_id: "37d082c7-c019-4ecd-8655-94f8f27807ff"
|
||||||
```
|
```
|
||||||
@@ -70,7 +95,7 @@ Output: documented storage layout, schema version fields, and fixture examples.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0003-T02
|
id: PMEM-WP-0003-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c417f7ec-0423-4723-91c3-3b4681e30ec3"
|
state_hub_task_id: "c417f7ec-0423-4723-91c3-3b4681e30ec3"
|
||||||
```
|
```
|
||||||
@@ -90,7 +115,7 @@ Output: adapter implementation and tests for round-trip behavior.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0003-T03
|
id: PMEM-WP-0003-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "1d3b3ffb-fc9b-401f-a77f-cfad4a4f6b72"
|
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
|
```task
|
||||||
id: PMEM-WP-0003-T04
|
id: PMEM-WP-0003-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "43d5863c-f2db-441e-8f3e-7e1843b6bc33"
|
state_hub_task_id: "43d5863c-f2db-441e-8f3e-7e1843b6bc33"
|
||||||
```
|
```
|
||||||
@@ -131,7 +156,7 @@ abandon flows without requiring transcript storage.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0003-T05
|
id: PMEM-WP-0003-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "e9079c0c-5834-47b2-b1dc-1d97604c96f8"
|
state_hub_task_id: "e9079c0c-5834-47b2-b1dc-1d97604c96f8"
|
||||||
```
|
```
|
||||||
@@ -147,7 +172,7 @@ unapproved durable actions are denied.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0003-T06
|
id: PMEM-WP-0003-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "a0597e3e-2f2d-4abf-8fc2-37c914e0ce34"
|
state_hub_task_id: "a0597e3e-2f2d-4abf-8fc2-37c914e0ce34"
|
||||||
```
|
```
|
||||||
@@ -166,7 +191,7 @@ Output: CLI/runtime helpers and tests for useful diagnostics.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0003-T07
|
id: PMEM-WP-0003-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "60b3bce7-3e73-427b-95d7-d68283503a3c"
|
state_hub_task_id: "60b3bce7-3e73-427b-95d7-d68283503a3c"
|
||||||
```
|
```
|
||||||
@@ -186,3 +211,26 @@ Output: local persistence guide and README pointers.
|
|||||||
marker.
|
marker.
|
||||||
- Conversational path branches and merges are represented as structured memory
|
- Conversational path branches and merges are represented as structured memory
|
||||||
events, not only as transcript text.
|
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"
|
title: "Policy, Audit, And Review Gates"
|
||||||
domain: markitect
|
domain: markitect
|
||||||
repo: phase-memory
|
repo: phase-memory
|
||||||
status: proposed
|
status: finished
|
||||||
owner: phase-memory
|
owner: phase-memory
|
||||||
topic_slug: policy-audit
|
topic_slug: policy-audit
|
||||||
planning_priority: P1
|
planning_priority: P1
|
||||||
@@ -52,11 +52,38 @@ activations, writes, compactions, and phase transitions.
|
|||||||
- Do not make `flex-auth` a hard dependency.
|
- Do not make `flex-auth` a hard dependency.
|
||||||
- Do not store or expose secrets in test fixtures.
|
- 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
|
## T01 - Define memory operation policy points
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0004-T01
|
id: PMEM-WP-0004-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "1231b7bf-b23c-498d-a9d6-a6ee307aa3d4"
|
state_hub_task_id: "1231b7bf-b23c-498d-a9d6-a6ee307aa3d4"
|
||||||
```
|
```
|
||||||
@@ -82,7 +109,7 @@ the right boundary.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0004-T02
|
id: PMEM-WP-0004-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b989d43c-eb25-4663-afd1-a54673ad565a"
|
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
|
```task
|
||||||
id: PMEM-WP-0004-T03
|
id: PMEM-WP-0004-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6b677c18-7135-4d54-9e46-5116645d2ebe"
|
state_hub_task_id: "6b677c18-7135-4d54-9e46-5116645d2ebe"
|
||||||
```
|
```
|
||||||
@@ -122,7 +149,7 @@ and deletion requests cannot be applied silently.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0004-T04
|
id: PMEM-WP-0004-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6f07087b-e6e2-469a-9bce-71bfd21cb633"
|
state_hub_task_id: "6f07087b-e6e2-469a-9bce-71bfd21cb633"
|
||||||
```
|
```
|
||||||
@@ -143,7 +170,7 @@ for policy-denied records.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0004-T05
|
id: PMEM-WP-0004-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "bb6461a8-9181-4b88-a152-334668b22208"
|
state_hub_task_id: "bb6461a8-9181-4b88-a152-334668b22208"
|
||||||
```
|
```
|
||||||
@@ -169,7 +196,7 @@ apply operations.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0004-T06
|
id: PMEM-WP-0004-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "dcdec3af-d20f-43ba-b12e-6febc4347d38"
|
state_hub_task_id: "dcdec3af-d20f-43ba-b12e-6febc4347d38"
|
||||||
```
|
```
|
||||||
@@ -183,7 +210,7 @@ Output: redaction utility, denied activation examples, and regression tests.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: PMEM-WP-0004-T07
|
id: PMEM-WP-0004-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "c4e0bdff-5047-4fe5-ab86-e422d4b1a17e"
|
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.
|
- Review-required actions fail closed without an explicit review record.
|
||||||
- The policy layer remains adapter-based and does not become an identity
|
- The policy layer remains adapter-based and does not become an identity
|
||||||
platform.
|
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