Add profile-driven lifecycle rules

This commit is contained in:
2026-05-18 22:20:14 +02:00
parent 322571c02c
commit 908494b712
12 changed files with 700 additions and 14 deletions

View File

@@ -40,6 +40,7 @@ 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 lifecycle tests/fixtures/memory-graph.json --profile tests/fixtures/memory-profile.json
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
```
@@ -78,7 +79,7 @@ and a package compilation request for the `ContextPackageCompiler` boundary.
and plan envelopes.
- `phase_memory.contracts`: Markitect-compatible profile/graph ingress.
- `phase_memory.planner`: profile execution planning.
- `phase_memory.lifecycle`: dry-run lifecycle planning.
- `phase_memory.lifecycle`: dry-run and profile-driven lifecycle planning.
- `phase_memory.activation`: Markitect-compatible activation selection planning.
- `phase_memory.ports`: runtime port protocols.
- `phase_memory.adapters`: deterministic in-memory test adapters.
@@ -91,5 +92,6 @@ file-backed adapter, [docs/policy-audit.md](docs/policy-audit.md) for local
policy and review gates, [docs/markitect-interop.md](docs/markitect-interop.md)
for package bridge boundaries, [docs/activation-quality.md](docs/activation-quality.md)
for retrieval and evaluation behavior, [docs/service-readiness.md](docs/service-readiness.md)
for service and adapter contracts, and [SCOPE.md](SCOPE.md) for repository
for service and adapter contracts, [docs/lifecycle-rules.md](docs/lifecycle-rules.md)
for profile-driven lifecycle rules, and [SCOPE.md](SCOPE.md) for repository
boundaries.

78
docs/lifecycle-rules.md Normal file
View File

@@ -0,0 +1,78 @@
# Profile-Driven Lifecycle Rules
Lifecycle planning can now derive its first local rule set from a
Markitect-compatible memory profile.
## Retention Rules
`LifecycleRuleConfig.from_profile(...)` reads retention rules from:
```json
{
"retention": {
"default": {"stale_after_days": 7, "delete_after_days": 30},
"episode": {"stale_after_days": 3}
}
}
```
The fixture style is also supported:
```json
{
"retention": {
"conversation": {"stale_after_days": 7, "delete_after_days": 30}
}
}
```
When a profile has one nested retention rule and no explicit `default`, that
rule becomes the default for local planning. This keeps older profile fixtures
useful while more specific node-kind rules evolve.
## Phase Transition Rules
The first transition rule format lives in profile metadata:
```json
{
"metadata": {
"phase_transitions": [
{
"kind": "episode",
"from_phase": "fluid",
"to_phase": "stabilized",
"min_age_days": 7,
"reason": "episode old enough to stabilize"
}
]
}
}
```
Transitions into `stabilized` or `rigid` still require review. The rule only
creates a dry-run `transition_phase` action.
## Refresh And Compaction
Refresh actions are only produced when the profile enables refresh and the
caller supplies source digests:
```bash
phase-memory graph lifecycle tests/fixtures/memory-graph.json \
--profile tests/fixtures/memory-profile.json \
--refresh-digest event.restart=new
```
Compaction can be requested through `--compact-node` or profile compaction
metadata with `node_ids` / `compact_node_ids`.
## Runtime API
Use `PhaseMemoryRuntime.plan_lifecycle_with_profile(...)` when callers have a
profile and graph envelope. The returned runtime envelope includes:
- `dry_run_actions`
- `profile_id`
- `rule_config`
- source digest and compaction parameters

View File

@@ -11,10 +11,16 @@ from .bridge import (
)
from .contracts import graph_from_markitect, profile_from_markitect
from .lifecycle import (
LifecycleRuleConfig,
PhaseTransitionRule,
RetentionRule,
plan_compaction,
plan_lifecycle_from_profile,
plan_phase_transition,
plan_phase_transitions_from_rules,
plan_refresh,
plan_retention,
plan_retention_from_rules,
)
from .models import (
ActivationPlan,
@@ -69,6 +75,9 @@ __all__ = [
"ReviewDecision",
"ReviewRecord",
"PhaseMemoryRuntime",
"LifecycleRuleConfig",
"PhaseTransitionRule",
"RetentionRule",
"POLICY_OPERATION_POINTS",
"MemoryOperation",
"MARKITECT_PACKAGE_REQUEST_SCHEMA",
@@ -84,10 +93,13 @@ __all__ = [
"make_review_record",
"plan_activation",
"plan_compaction",
"plan_lifecycle_from_profile",
"plan_phase_transition",
"plan_phase_transitions_from_rules",
"plan_profile_execution",
"plan_refresh",
"plan_retention",
"plan_retention_from_rules",
"profile_from_markitect",
"path_event",
"package_request_from_selection",

View File

@@ -41,6 +41,7 @@ def build_parser() -> argparse.ArgumentParser:
lifecycle = graph_subparsers.add_parser("lifecycle", help="Plan graph lifecycle actions")
lifecycle.add_argument("graph", type=Path)
lifecycle.add_argument("--profile", type=Path, help="Derive lifecycle thresholds and rules from a memory profile")
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")
@@ -91,6 +92,15 @@ def _profile_plan(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict
def _graph_lifecycle(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
if args.profile:
return runtime.plan_lifecycle_with_profile(
_read_json(args.profile),
_read_json(args.graph),
source_ref=str(args.graph),
profile_source_ref=str(args.profile),
refresh_digests=_parse_digest_args(args.refresh_digest),
compact_node_ids=tuple(args.compact_node),
)
return runtime.plan_lifecycle(
_read_json(args.graph),
source_ref=str(args.graph),

View File

@@ -2,12 +2,104 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from .models import LifecycleAction, LifecycleActionKind, LifecycleState, MemoryNode, MemoryPhase
from .models import LifecycleAction, LifecycleActionKind, LifecycleState, MemoryGraph, MemoryNode, MemoryPhase, ProfileIntent
from .utils import parse_iso_datetime, stable_digest
@dataclass(frozen=True)
class RetentionRule:
node_kind: str = ""
stale_after_days: int | None = None
delete_after_days: int | None = None
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {}
if self.node_kind:
data["node_kind"] = self.node_kind
if self.stale_after_days is not None:
data["stale_after_days"] = self.stale_after_days
if self.delete_after_days is not None:
data["delete_after_days"] = self.delete_after_days
return data
@dataclass(frozen=True)
class PhaseTransitionRule:
target_phase: MemoryPhase
node_kind: str = ""
from_phase: MemoryPhase | None = None
min_age_days: int | None = None
reason: str = ""
def matches(self, node: MemoryNode, *, now: datetime) -> bool:
if self.node_kind and node.kind != self.node_kind:
return False
if self.from_phase is not None and node.phase != self.from_phase:
return False
if self.min_age_days is not None:
age = _age_days(node, now)
if age is None or age < self.min_age_days:
return False
return node.phase != self.target_phase
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {"target_phase": self.target_phase.value}
if self.node_kind:
data["node_kind"] = self.node_kind
if self.from_phase is not None:
data["from_phase"] = self.from_phase.value
if self.min_age_days is not None:
data["min_age_days"] = self.min_age_days
if self.reason:
data["reason"] = self.reason
return data
@dataclass(frozen=True)
class LifecycleRuleConfig:
retention_default: RetentionRule | None = None
retention_by_kind: dict[str, RetentionRule] | None = None
transition_rules: tuple[PhaseTransitionRule, ...] = ()
refresh_enabled: bool = False
compact_node_ids: tuple[str, ...] = ()
@classmethod
def from_profile(cls, profile: ProfileIntent | dict[str, Any]) -> "LifecycleRuleConfig":
profile_intent = profile if isinstance(profile, ProfileIntent) else ProfileIntent.from_mapping(profile)
retention_default, retention_by_kind = _retention_rules(profile_intent.retention)
return cls(
retention_default=retention_default,
retention_by_kind=retention_by_kind,
transition_rules=_transition_rules(profile_intent),
refresh_enabled=_refresh_enabled(profile_intent.refresh),
compact_node_ids=_string_tuple(
profile_intent.compaction.get("node_ids")
or profile_intent.compaction.get("compact_node_ids")
or ()
),
)
def retention_for(self, node: MemoryNode) -> RetentionRule | None:
by_kind = self.retention_by_kind or {}
return by_kind.get(node.kind) or self.retention_default
def to_dict(self) -> dict[str, Any]:
return {
"retention_default": self.retention_default.to_dict() if self.retention_default else None,
"retention_by_kind": {
key: value.to_dict()
for key, value in sorted((self.retention_by_kind or {}).items())
},
"transition_rules": [rule.to_dict() for rule in self.transition_rules],
"refresh_enabled": self.refresh_enabled,
"compact_node_ids": list(self.compact_node_ids),
}
def plan_phase_transition(
node: MemoryNode,
target_phase: MemoryPhase,
@@ -67,6 +159,47 @@ def plan_retention(
return tuple(actions)
def plan_retention_from_rules(
nodes: list[MemoryNode] | tuple[MemoryNode, ...],
config: LifecycleRuleConfig,
*,
now: datetime | None = None,
) -> tuple[LifecycleAction, ...]:
actions: list[LifecycleAction] = []
for node in nodes:
rule = config.retention_for(node)
if rule is None:
continue
actions.extend(
plan_retention(
[node],
stale_after_days=rule.stale_after_days,
delete_after_days=rule.delete_after_days,
now=now,
)
)
return tuple(actions)
def plan_phase_transitions_from_rules(
nodes: list[MemoryNode] | tuple[MemoryNode, ...],
config: LifecycleRuleConfig,
*,
now: datetime | None = None,
) -> tuple[LifecycleAction, ...]:
now = now or datetime.now(timezone.utc)
actions: list[LifecycleAction] = []
seen: set[tuple[str, str]] = set()
for rule in config.transition_rules:
for node in nodes:
key = (node.node_id, rule.target_phase.value)
if key in seen or not rule.matches(node, now=now):
continue
actions.append(plan_phase_transition(node, rule.target_phase, reason=rule.reason))
seen.add(key)
return tuple(actions)
def plan_compaction(
nodes: list[MemoryNode] | tuple[MemoryNode, ...],
*,
@@ -113,6 +246,29 @@ def plan_refresh(
return tuple(actions)
def plan_lifecycle_from_profile(
graph: MemoryGraph,
profile: ProfileIntent | dict[str, Any],
*,
refresh_digests: dict[str, str] | None = None,
compact_node_ids: tuple[str, ...] = (),
now: datetime | None = None,
) -> tuple[LifecycleAction, ...]:
config = LifecycleRuleConfig.from_profile(profile)
actions: list[LifecycleAction] = []
actions.extend(plan_retention_from_rules(graph.nodes, config, now=now))
actions.extend(plan_phase_transitions_from_rules(graph.nodes, config, now=now))
if config.refresh_enabled and refresh_digests:
actions.extend(plan_refresh(graph.nodes, source_digest_by_node_id=refresh_digests))
by_id = graph.node_by_id()
compact_ids = tuple(dict.fromkeys((*config.compact_node_ids, *compact_node_ids)))
compact_nodes = [by_id[node_id] for node_id in compact_ids if node_id in by_id]
if compact_nodes:
actions.append(plan_compaction(compact_nodes))
return tuple(actions)
def _age_days(node: MemoryNode, now: datetime) -> int | None:
updated = parse_iso_datetime(str(node.freshness.get("updated_at") or node.updated_at))
if updated is None:
@@ -125,3 +281,102 @@ def _summary_text(nodes: list[MemoryNode] | tuple[MemoryNode, ...]) -> str:
if not parts:
return "Summary proposed for selected memory nodes."
return " ".join(parts)[:240]
def _retention_rules(data: dict[str, Any]) -> tuple[RetentionRule | None, dict[str, RetentionRule]]:
if not data:
return None, {}
default = _retention_rule("", data) if _is_retention_rule(data) else None
by_kind: dict[str, RetentionRule] = {}
for key, value in data.items():
if not isinstance(value, dict):
continue
rule = _retention_rule("" if key == "default" else str(key), value)
if key == "default":
default = rule
else:
by_kind[str(key)] = rule
if default is None and len(by_kind) == 1:
only = next(iter(by_kind.values()))
default = RetentionRule(stale_after_days=only.stale_after_days, delete_after_days=only.delete_after_days)
return default, by_kind
def _retention_rule(node_kind: str, data: dict[str, Any]) -> RetentionRule:
return RetentionRule(
node_kind=node_kind,
stale_after_days=_optional_int(data.get("stale_after_days")),
delete_after_days=_optional_int(data.get("delete_after_days")),
)
def _is_retention_rule(data: dict[str, Any]) -> bool:
return "stale_after_days" in data or "delete_after_days" in data
def _transition_rules(profile: ProfileIntent) -> tuple[PhaseTransitionRule, ...]:
raw_rules = (
profile.metadata.get("phase_transitions")
or profile.metadata.get("transition_rules")
or _metadata_lifecycle(profile).get("phase_transitions")
or ()
)
rules: list[PhaseTransitionRule] = []
for item in (raw_rules if isinstance(raw_rules, (list, tuple)) else ()):
if not isinstance(item, dict):
continue
target_phase = _phase(item.get("to_phase") or item.get("target_phase"))
if target_phase is None:
continue
when = item.get("when") if isinstance(item.get("when"), dict) else {}
min_age_days = item.get("min_age_days")
if min_age_days is None:
min_age_days = when.get("min_age_days")
rules.append(
PhaseTransitionRule(
target_phase=target_phase,
node_kind=str(item.get("kind") or item.get("node_kind") or ""),
from_phase=_phase(item.get("from_phase")),
min_age_days=_optional_int(min_age_days),
reason=str(item.get("reason") or ""),
)
)
return tuple(rules)
def _metadata_lifecycle(profile: ProfileIntent) -> dict[str, Any]:
lifecycle = profile.metadata.get("lifecycle")
return dict(lifecycle) if isinstance(lifecycle, dict) else {}
def _refresh_enabled(data: dict[str, Any]) -> bool:
if not data:
return False
trigger = str(data.get("trigger") or data.get("mode") or "enabled").lower()
return trigger not in {"disabled", "off", "none", "false"}
def _phase(value: Any) -> MemoryPhase | None:
if value is None:
return None
try:
return MemoryPhase(str(value))
except ValueError:
return None
def _optional_int(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _string_tuple(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
return (value,)
return tuple(str(item) for item in value)

View File

@@ -17,7 +17,7 @@ from .adapters import (
)
from .bridge import MARKITECT_PACKAGE_REQUEST_SCHEMA, package_request_from_selection, package_response_envelope
from .contracts import ContractIngressResult, graph_from_markitect, profile_from_markitect
from .lifecycle import plan_compaction, plan_refresh, plan_retention
from .lifecycle import LifecycleRuleConfig, plan_compaction, plan_lifecycle_from_profile, plan_refresh, plan_retention
from .models import (
Diagnostic,
LifecycleAction,
@@ -150,6 +150,57 @@ class PhaseMemoryRuntime:
},
)
def plan_lifecycle_with_profile(
self,
profile_data: dict[str, Any],
graph_data: dict[str, Any],
*,
source_ref: str = "mapping",
profile_source_ref: str = "profile",
refresh_digests: dict[str, str] | None = None,
compact_node_ids: tuple[str, ...] = (),
now: datetime | None = None,
) -> dict[str, Any]:
profile_result = profile_from_markitect(profile_data)
graph_result = graph_from_markitect(graph_data)
diagnostics = list(profile_result.diagnostics) + list(graph_result.diagnostics)
actions: tuple[LifecycleAction, ...] = ()
rule_config = None
if profile_result.valid and graph_result.valid:
profile: ProfileIntent = profile_result.value
graph: MemoryGraph = graph_result.value
rule_config = LifecycleRuleConfig.from_profile(profile)
actions = plan_lifecycle_from_profile(
graph,
profile,
refresh_digests=refresh_digests or {},
compact_node_ids=compact_node_ids,
now=now,
)
return self._envelope(
"graph.lifecycle.plan",
subject_kind="memory_graph",
subject_id=graph_result.subject_id,
valid=profile_result.valid and graph_result.valid,
diagnostics=diagnostics,
source_ref=source_ref,
data={
"graph_id": graph_result.subject_id,
"profile_id": profile_result.subject_id,
"dry_run_actions": [action.to_dict() for action in actions],
"rule_config": rule_config.to_dict() if rule_config else None,
"parameters": compact_dict(
{
"profile_source_ref": profile_source_ref,
"refresh_digests": refresh_digests or {},
"compact_node_ids": list(compact_node_ids),
}
),
},
)
def plan_activation(
self,
data: dict[str, Any],

View File

@@ -29,7 +29,7 @@ KONTEXTUAL_DELEGATION_SCHEMA = "phase_memory.kontextual.delegation.v1"
SERVICE_OPERATIONS = {
"profile.plan": {"request": ["profile"], "response": "runtime_envelope"},
"graph.import": {"request": ["graph"], "response": "runtime_envelope"},
"graph.lifecycle.plan": {"request": ["graph", "parameters"], "response": "runtime_envelope"},
"graph.lifecycle.plan": {"request": ["graph", "parameters", "profile?"], "response": "runtime_envelope"},
"lifecycle.apply": {"request": ["actions", "review_record"], "response": "runtime_envelope"},
"graph.activation.plan": {"request": ["graph", "budget"], "response": "runtime_envelope"},
"package.compile": {"request": ["selection"], "response": "runtime_envelope"},
@@ -353,6 +353,25 @@ class LocalServiceRunner:
return self.runtime.plan_profile(payload["profile"], source_ref=payload.get("source_ref", "service"))
if operation == "graph.import":
return self.runtime.import_graph(payload["graph"], source_ref=payload.get("source_ref", "service"))
if operation == "graph.lifecycle.plan":
parameters = payload.get("parameters", {})
if payload.get("profile"):
return self.runtime.plan_lifecycle_with_profile(
payload["profile"],
payload["graph"],
source_ref=payload.get("source_ref", "service"),
profile_source_ref=payload.get("profile_source_ref", "service-profile"),
refresh_digests=dict(parameters.get("refresh_digests") or {}),
compact_node_ids=tuple(parameters.get("compact_node_ids") or ()),
)
return self.runtime.plan_lifecycle(
payload["graph"],
source_ref=payload.get("source_ref", "service"),
stale_after_days=parameters.get("stale_after_days"),
delete_after_days=parameters.get("delete_after_days"),
refresh_digests=dict(parameters.get("refresh_digests") or {}),
compact_node_ids=tuple(parameters.get("compact_node_ids") or ()),
)
if operation == "graph.activation.plan":
budget = payload.get("budget", {})
return self.runtime.plan_activation(

View File

@@ -47,6 +47,29 @@ def test_cli_graph_lifecycle_emits_dry_run_actions(capsys) -> None:
assert [action["action"] for action in output["data"]["dry_run_actions"]][:2] == ["mark_stale", "refresh"]
def test_cli_graph_lifecycle_can_use_profile_rules(capsys) -> None:
code = main(
[
"graph",
"lifecycle",
str(FIXTURES / "memory-graph.json"),
"--profile",
str(FIXTURES / "memory-profile.json"),
"--refresh-digest",
"event.restart=new",
]
)
output = json.loads(capsys.readouterr().out)
actions = {(action["target_id"], action["action"]) for action in output["data"]["dry_run_actions"]}
assert code == 0
assert output["operation"] == "graph.lifecycle.plan"
assert output["data"]["profile_id"] == "phase-memory-fixture-profile"
assert output["data"]["rule_config"]["retention_default"]["stale_after_days"] == 7
assert ("event.restart", "mark_stale") in actions
assert ("event.restart", "refresh") in actions
def test_cli_graph_activate_emits_selection(capsys) -> None:
code = main(
[

View File

@@ -1,7 +1,24 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from phase_memory.lifecycle import plan_compaction, plan_phase_transition, plan_refresh, plan_retention
from phase_memory.models import LifecycleActionKind, LifecycleState, MemoryNode, MemoryPhase
from phase_memory.contracts import graph_from_markitect
from phase_memory.lifecycle import (
LifecycleRuleConfig,
plan_compaction,
plan_lifecycle_from_profile,
plan_phase_transition,
plan_refresh,
plan_retention,
)
from phase_memory.models import LifecycleActionKind, LifecycleState, MemoryGraph, MemoryNode, MemoryPhase
FIXTURES = Path(__file__).parent / "fixtures"
def _load(name: str):
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def test_phase_transition_to_stabilized_requires_review() -> None:
@@ -47,3 +64,61 @@ def test_compaction_and_refresh_are_reviewable_plans() -> None:
assert refresh.action == LifecycleActionKind.REFRESH
assert refresh.requires_review
assert refresh.metadata["proposed_digest"] == "new"
def test_profile_retention_rules_drive_lifecycle_plan_from_fixture() -> None:
now = datetime(2026, 5, 18, tzinfo=timezone.utc)
profile = _load("memory-profile.json")
graph = graph_from_markitect(_load("memory-graph.json")).value
config = LifecycleRuleConfig.from_profile(profile)
actions = plan_lifecycle_from_profile(
graph,
profile,
refresh_digests={"event.restart": "new"},
now=now,
)
by_target_and_action = {(action.target_id, action.action): action for action in actions}
assert config.retention_default.stale_after_days == 7
assert by_target_and_action[("event.restart", LifecycleActionKind.MARK_STALE)].to_state == LifecycleState.STALE
assert by_target_and_action[("event.restart", LifecycleActionKind.REFRESH)].requires_review
def test_profile_transition_rules_promote_matching_nodes() -> None:
now = datetime(2026, 5, 18, tzinfo=timezone.utc)
graph = MemoryGraph(
"graph.rules",
nodes=(
MemoryNode(
"episode.old",
"episode",
"Useful trace",
phase=MemoryPhase.FLUID,
freshness={"updated_at": "2026-05-01T00:00:00+00:00"},
),
),
)
profile = {
"schema_version": "markitect.memory.profile.v1",
"id": "profile.rules",
"metadata": {
"phase_transitions": [
{
"kind": "episode",
"from_phase": "fluid",
"to_phase": "stabilized",
"min_age_days": 7,
"reason": "episode old enough to stabilize",
}
]
},
}
actions = plan_lifecycle_from_profile(graph, profile, now=now)
assert len(actions) == 1
assert actions[0].action == LifecycleActionKind.TRANSITION_PHASE
assert actions[0].target_id == "episode.old"
assert actions[0].requires_review is True
assert actions[0].metadata["to_phase"] == "stabilized"

View File

@@ -58,6 +58,24 @@ def test_service_runner_handles_health() -> None:
assert response["ok"] is True
def test_service_runner_handles_profile_driven_lifecycle_plan() -> None:
runner = LocalServiceRunner()
response = runner.handle(
"graph.lifecycle.plan",
{
"profile": _load("memory-profile.json"),
"graph": _load("memory-graph.json"),
"parameters": {"refresh_digests": {"event.restart": "new"}},
},
)
actions = {(action["target_id"], action["action"]) for action in response["data"]["dry_run_actions"]}
assert response["operation"] == "graph.lifecycle.plan"
assert response["data"]["profile_id"] == "phase-memory-fixture-profile"
assert ("event.restart", "refresh") in actions
def test_profile_driven_runtime_config_resolves_file_backed_adapters(tmp_path) -> None:
config = RuntimeConfig.from_profile(
{

View File

@@ -44,13 +44,14 @@ not what adjacent repositories may already provide.
## Current Baseline - 2026-05-18
Overall maturity: **4.3 / 5**
Overall maturity: **4.4 / 5**
The repo has crossed from intent-only into a working deterministic library
foundation, a usable local runtime facade, a CLI, a file-backed local
workspace, first-slice policy/review/audit gates, a concrete Markitect package
bridge, deterministic activation quality helpers, and first-slice service
readiness contracts with profile-driven runtime adapter resolution.
readiness contracts with profile-driven runtime adapter resolution and
profile-derived lifecycle rules.
| Dimension | Current | Target | Evidence | Needed Next |
| --- | ---: | ---: | --- | --- |
@@ -58,18 +59,18 @@ readiness contracts with profile-driven runtime adapter resolution.
| Package foundation | 4.0 | 4.0 | Python package, exports, runtime facade, CLI entrypoint, config, service contracts, dependency-light tests | Maintain public API compatibility as adapters expand. |
| Profile contract ingress | 3.2 | 4.0 | Markitect-compatible profile loading, diagnostics, runtime envelopes, profile-derived runtime config, local adapter alias normalization | Add richer compatibility coverage. |
| Graph/event contract ingress | 3.5 | 4.0 | Graph loading, edge endpoint diagnostics, event model, JSONL event log, export, repair diagnostics, service import/export contracts | Add broader external adapter fixtures. |
| Phase domain model | 3.0 | 4.0 | Phases, memory kinds, lifecycle states, actions, explicit path records | Add profile-driven transition rule evaluation and migration semantics. |
| Phase domain model | 3.4 | 4.0 | Phases, memory kinds, lifecycle states, actions, explicit path records, profile-derived transition rules | Add migration semantics. |
| Profile execution planning | 3.8 | 4.0 | Adapter plan, capabilities, policy gates, fallback behavior, CLI output, snapshot fixture, service contract, config-driven local adapter resolution | Add external adapter injection coverage. |
| Lifecycle planning | 3.0 | 4.0 | Transition, retention, refresh, compaction dry-run plans, review-gated local apply | Add profile-driven rule evaluation and service apply contracts. |
| Lifecycle planning | 3.6 | 4.0 | Transition, retention, refresh, compaction dry-run plans, profile-driven lifecycle rules, review-gated local apply | Add service apply contracts and migration semantics. |
| Activation planning | 3.8 | 5.0 | Budgeted selection, Markitect-compatible selection output, package request envelope, graph neighborhoods, event paths, ranking, metadata preservation, metrics | Add semantic-index adapters and broader evaluation corpora. |
| Local persistence | 3.0 | 4.0 | Versioned local workspace, file-backed graph store, JSONL event log, JSONL audit sink | Add migration/repair utilities and stronger durability semantics. |
| Policy and audit | 3.2 | 5.0 | Operation points, policy gateway checks, audit schema, review records, redaction, activation denials | Add external policy adapters and richer audit retention behavior. |
| Observability and diagnostics | 3.5 | 4.0 | Planner diagnostics, runtime diagnostics, event log corruption checks, repair diagnostics, policy denial diagnostics, health envelopes, adapter status | Add production telemetry adapters. |
| Markitect interop | 3.5 | 4.0 | Compatible contract ingress, optional validation boundary, enriched selection metadata, package request/response envelopes | Add live optional Markitect compiler adapter when available. |
| Kontextual/Infospace interop | 2.5 | 4.0 | Boundaries documented, small derived fixtures, activation quality report fixture, Kontextual delegation envelope | Add live fake/real delegation adapters and broader Infospace reports. |
| Testing and evaluation | 4.0 | 4.0 | 54 deterministic tests over planners, adapters, runtime envelopes, CLI, snapshots, file-store round trips, apply denial, review records, audit schema, policy redaction, Markitect bridge fixtures, retrieval, activation metrics, service contracts, config, health, conformance, and adapter resolution | Add broader evaluation corpora. |
| Service readiness | 3.8 | 4.0 | Runtime ports, service contracts, config model, profile-derived adapter resolution, health checks, local service runner, adapter conformance helpers | Add framework-specific bindings and production adapter packs. |
| Developer experience | 3.5 | 4.0 | README quick start, package map, runtime facade docs, CLI examples, local persistence guide, service adapter resolution docs | Add troubleshooting and richer examples. |
| Testing and evaluation | 4.0 | 4.0 | 58 deterministic tests over planners, adapters, runtime envelopes, CLI, snapshots, file-store round trips, lifecycle rules, apply denial, review records, audit schema, policy redaction, Markitect bridge fixtures, retrieval, activation metrics, service contracts, config, health, conformance, and adapter resolution | Add broader evaluation corpora. |
| Service readiness | 3.9 | 4.0 | Runtime ports, service contracts, config model, profile-derived adapter resolution, health checks, local service runner lifecycle support, adapter conformance helpers | Add framework-specific bindings and production adapter packs. |
| Developer experience | 3.6 | 4.0 | README quick start, package map, runtime facade docs, CLI examples, local persistence guide, service adapter resolution docs, lifecycle rule docs | Add troubleshooting and richer examples. |
## Progress Update - PMEM-WP-0002
@@ -173,6 +174,26 @@ Remaining maturity blockers:
- Optional framework-specific service bindings.
- Production telemetry and audit retention integrations.
## Progress Update - PMEM-WP-0009
Closed on 2026-05-18:
- Added `LifecycleRuleConfig` for profile-derived retention, transition,
refresh, and compaction settings.
- Added profile-derived retention planning.
- Added review-gated phase transition rules from profile metadata.
- Added `plan_lifecycle_from_profile(...)`.
- Added `PhaseMemoryRuntime.plan_lifecycle_with_profile(...)`.
- Added CLI `graph lifecycle --profile`.
- Added lifecycle rule docs, README examples, and regression tests.
Remaining maturity blockers:
- Migration semantics for profile/rule changes over durable stores.
- Service apply contracts.
- Live external adapter implementations.
- Broader evaluation corpora.
## Progress Update - PMEM-WP-0004
Closed on 2026-05-18:
@@ -237,6 +258,7 @@ flowchart TD
WP6["PMEM-WP-0006\nRetrieval and activation quality"]
WP7["PMEM-WP-0007\nService readiness and adapters"]
WP8["PMEM-WP-0008\nProfile-driven runtime config"]
WP9["PMEM-WP-0009\nProfile-driven lifecycle rules"]
WP1 --> WP2
WP2 --> WP3
@@ -252,6 +274,9 @@ flowchart TD
WP3 --> WP8
WP5 --> WP8
WP7 --> WP8
WP2 --> WP9
WP4 --> WP9
WP8 --> WP9
```
## Next Tracking Cadence

View File

@@ -0,0 +1,118 @@
---
id: PMEM-WP-0009
type: workplan
title: "Profile-Driven Lifecycle Rules"
domain: markitect
repo: phase-memory
status: finished
owner: codex
topic_slug: phase-memory
created: "2026-05-18"
updated: "2026-05-18"
---
# PMEM-WP-0009: Profile-Driven Lifecycle Rules
## Goal
Let Markitect-compatible memory profiles drive lifecycle planning directly,
instead of requiring every retention, refresh, compaction, and transition
parameter to be passed by hand.
This moves the phase domain model and lifecycle planning rows toward their
target maturity by turning profile policy into executable dry-run behavior.
## Current Evidence
PMEM-WP-0008 added profile-derived runtime configuration. Lifecycle planning
still depended on explicit CLI/runtime parameters for stale/delete thresholds,
refresh digests, and compaction targets.
## Non-Goals
- Mutate durable lifecycle state automatically.
- Add physical delete behavior.
- Replace review gates for stabilization, rigid promotion, refresh, or
compaction.
- Build a profile migration engine.
## Implementation Update - 2026-05-18
Implemented the first profile-driven lifecycle rules slice:
- Added `LifecycleRuleConfig` with retention, transition, refresh, and
compaction rule extraction from `ProfileIntent` or mappings.
- Added profile-derived retention planning.
- Added profile metadata phase transition rules.
- Added `plan_lifecycle_from_profile(...)`.
- Added `PhaseMemoryRuntime.plan_lifecycle_with_profile(...)`.
- Added CLI `graph lifecycle --profile`.
- Added local service runner support for profile-driven lifecycle planning.
- Added docs, README examples, and regression tests.
## T01 - Add lifecycle rule config
```task
id: PMEM-WP-0009-T01
status: done
priority: high
```
Derive retention, transition, refresh, and compaction rule settings from a
profile.
## T02 - Plan retention from profile rules
```task
id: PMEM-WP-0009-T02
status: done
priority: high
```
Support default retention rules and node-kind-specific retention rules.
## T03 - Plan phase transitions from profile metadata
```task
id: PMEM-WP-0009-T03
status: done
priority: medium
```
Support first-slice metadata transition rules with node kind, source phase,
target phase, minimum age, and review-gated transition actions.
## T04 - Wire runtime and CLI profile lifecycle planning
```task
id: PMEM-WP-0009-T04
status: done
priority: high
```
Expose profile-driven lifecycle planning through the runtime facade and CLI.
Include local service runner handling for the same contract.
## T05 - Document and test the rule path
```task
id: PMEM-WP-0009-T05
status: done
priority: medium
```
Add lifecycle rule docs, README examples, fixture coverage, transition rule
coverage, and CLI regression coverage.
## Acceptance Criteria
- Profile retention rules can produce lifecycle actions.
- Profile transition metadata can produce review-gated phase transitions.
- Refresh and compaction remain deliberate caller/profile inputs.
- Runtime and CLI envelopes expose the derived rule config.
- Tests cover fixture-derived rules and explicit transition rules.
## Closure Review - 2026-05-18
Closed after adding executable profile lifecycle rules, runtime/CLI support,
documentation, and deterministic regression tests.