diff --git a/README.md b/README.md index 0164a5a..563484f 100644 --- a/README.md +++ b/README.md @@ -96,5 +96,6 @@ for retrieval and evaluation behavior, [docs/service-readiness.md](docs/service- for service and adapter contracts, [docs/lifecycle-rules.md](docs/lifecycle-rules.md) for profile-driven lifecycle rules, [docs/external-adapter-packs.md](docs/external-adapter-packs.md) for fake external integration packs, [docs/operational-readiness.md](docs/operational-readiness.md) -for the local end-to-end operational recipe, [docs/maturity-scorecard.md](docs/maturity-scorecard.md) +for the local end-to-end operational recipe, [docs/api-compatibility.md](docs/api-compatibility.md) +for public API compatibility expectations, [docs/maturity-scorecard.md](docs/maturity-scorecard.md) for the current maturity assessment, and [SCOPE.md](SCOPE.md) for repository boundaries. diff --git a/docs/api-compatibility.md b/docs/api-compatibility.md new file mode 100644 index 0000000..78827e8 --- /dev/null +++ b/docs/api-compatibility.md @@ -0,0 +1,46 @@ +# API Compatibility + +Updated: 2026-05-19 + +`phase-memory` now treats its embedding surface as a compatibility contract for +local integrations. + +## Stable Surface + +The public surface is: + +- exports listed in `phase_memory.__all__`; +- `PhaseMemoryRuntime` JSON-serializable runtime envelopes; +- `LocalServiceRunner.handle(operation, payload)`; +- every operation declared by `service_contracts()["operations"]`; +- `ServiceBinding` and `service_binding_from_config` as optional + framework-neutral binding helpers; +- `RuntimeConfig`, `resolve_runtime_adapters`, and `runtime_from_config`; +- adapter pack manifests and `validate_adapter_pack_manifest`; +- evaluation threshold reports from `evaluation_threshold_report`. + +The snapshot in `tests/fixtures/public-api-snapshot.json` intentionally fails +when exports or service operations change. Update the snapshot only when the +change is deliberate and documented. + +## Adding Operations + +When adding a service operation: + +1. Add the operation to `SERVICE_OPERATIONS`. +2. Add a `LocalServiceRunner` dispatch path. +3. Add binding and runner tests that exercise the operation without starting a + network listener. +4. Update the API snapshot. +5. Note the change in docs or release notes. + +## Breaking Changes + +Breaking changes should include: + +- a migration note in this document or release notes; +- a compatibility test update explaining the intentional break; +- StateHub workplan evidence for why the break is needed. + +Avoid changing runtime envelope keys, service operation names, adapter manifest +schema fields, or fixture schema names without a migration path. diff --git a/docs/maturity-scorecard.md b/docs/maturity-scorecard.md index 19b9850..a96c73a 100644 --- a/docs/maturity-scorecard.md +++ b/docs/maturity-scorecard.md @@ -1,6 +1,6 @@ # Phase Memory Maturity Scorecard -Updated: 2026-05-18 +Updated: 2026-05-19 ## Purpose @@ -26,51 +26,51 @@ to 5. ## Current Score -Overall maturity: **4.0 / 5** +Overall maturity: **4.2 / 5** Two sub-scores make the result easier to reason about: -- Local integration maturity: **4.3 / 5** -- Operational maturity: **3.5 / 5** +- Local integration maturity: **4.5 / 5** +- Operational maturity: **3.8 / 5** The repo is strong as a deterministic local library and service-boundary core. -It is not yet production-operational because the external adapters are fakes, -service bindings are framework-neutral shapes rather than deployable endpoints, -and migration behavior is diagnostic rather than an operator-applied migration -system. +It is not yet production-operational because adapter coverage is still +live-shaped rather than credentialed live integration, and service bindings are +framework-neutral embedding surfaces rather than a deployed service. ## Dimension Scorecard | Dimension | Score | Target | Evidence | Needed Next | | --- | ---: | ---: | --- | --- | | Intent and boundaries | 4.4 | 5.0 | `INTENT.md`, `SCOPE.md`, `README.md`, architecture docs, adjacent-repo boundary docs | Keep docs current as live adapters and service bindings clarify real ownership. | -| Package and API foundation | 4.3 | 4.5 | Python package, public exports, runtime facade, CLI, service runner export, service config, dependency-light tests | Add public export compatibility checks and release notes discipline. | +| Package and API foundation | 4.5 | 4.8 | Python package, public exports, runtime facade, CLI, service runner export, service config, dependency-light tests, public API snapshot | Add release notes discipline and compatibility migration examples. | | Markitect profile contract ingress | 3.7 | 4.5 | Profile loading, diagnostics, runtime envelopes, profile-derived config, local alias normalization | Add richer compatibility fixtures and schema drift diagnostics. | -| Graph and event ingress | 3.9 | 4.5 | Graph loading, endpoint diagnostics, event model, JSONL log, export, repair checks, corrupt-record diagnostics, fake graph/event adapters | Add broader malformed/large graph fixtures and operator repair utilities. | +| Graph and event ingress | 4.0 | 4.5 | Graph loading, endpoint diagnostics, event model, JSONL log, export, repair checks, corrupt-record diagnostics, fake and live-shaped graph/event adapters | Add broader malformed/large graph fixtures and operator repair utilities. | | Phase domain model | 3.5 | 4.5 | Phases, lifecycle states, actions, paths, retention rules, profile-derived transition rules | Add migration semantics for profile/rule changes over durable stores. | -| Profile execution planning | 4.2 | 4.5 | Adapter plan, capabilities, policy gates, fallback behavior, config-driven local/external resolution, adapter pack manifests | Add compatibility gates for live adapter packs. | -| Lifecycle planning and apply | 4.0 | 4.5 | Dry-run lifecycle plans, profile rules, review-gated local apply, service `lifecycle.apply`, apply audit queries | Add operator migration semantics and richer apply rollback/repair drills. | +| Profile execution planning | 4.3 | 4.5 | Adapter plan, capabilities, policy gates, fallback behavior, config-driven local/external resolution, adapter pack manifests, live-shaped compatibility gates | Add compatibility gates for credentialed live adapter packs. | +| Lifecycle planning and apply | 4.1 | 4.5 | Dry-run lifecycle plans, profile rules, review-gated local apply, service `lifecycle.apply`, apply audit/export queries | Add richer apply rollback and repair drills. | | Activation planning | 4.0 | 4.8 | Budgeted activation, selections, package request, graph neighborhoods, paths, ranking, metrics, multi-scenario evaluation fixtures | Wire semantic-index-assisted retrieval into runtime planning. | -| Local persistence | 3.7 | 4.5 | File-backed graph store, JSONL event log, audit sink, atomic JSON writes, metadata migration diagnostics, export, repair diagnostics | Add executable migrations, compaction/retention utilities, and stronger corruption recovery. | -| Policy, review, and audit | 3.9 | 5.0 | Operation points, review records, audit schema, queryable audit sinks, denials, redaction, fake external policy/audit adapters | Add live policy adapter boundary and enforceable audit retention policy. | -| Observability and operations | 3.6 | 4.8 | Health report, config diagnostics, adapter status, fake telemetry audit sink, operational recipe | Add metrics/event export and deployable health/readiness binding. | -| Markitect interop | 3.7 | 4.5 | Local validation, package request/response envelopes, fake compiler | Add optional live Markitect compiler adapter and contract compatibility suite. | -| Kontextual/Infospace interop | 3.3 | 4.5 | Delegation envelope, fake runtime registry, activation quality report fixture, adapter compatibility manifests | Add live/fake delegation scenarios and broader Infospace restart reports. | -| Testing and evaluation | 4.1 | 4.5 | 70 deterministic tests over runtime, CLI, adapters, policy, activation, lifecycle, service, fakes, and evaluation scenarios | Add larger regression corpus and threshold trend reports. | -| Service readiness | 4.2 | 4.8 | Service contracts, full local runner parity, health, config, adapter conformance, fake pack | Add optional framework binding and deployable readiness endpoints. | -| Developer experience | 4.1 | 4.5 | README, package map, CLI examples, persistence/policy/interop/service/lifecycle/fake-pack docs, operational recipe | Add troubleshooting matrix and embedded-service examples. | +| Local persistence | 4.0 | 4.5 | File-backed graph store, JSONL event log, audit sink, atomic JSON writes, executable metadata migrations, migration audit, export, repair diagnostics | Add compaction/retention utilities and stronger corruption recovery. | +| Policy, review, and audit | 4.2 | 5.0 | Operation points, review records, audit schema, queryable/exportable audit sinks, retention plans, denials, redaction, fake/live-shaped policy/audit adapters | Add live policy adapter boundary and enforceable audit retention pruning. | +| Observability and operations | 4.0 | 4.8 | Health report, readiness report, config diagnostics, adapter status, service binding, fake/live-shaped telemetry audit sinks, operational recipe | Add metrics/event export to external telemetry and deployable service packaging. | +| Markitect interop | 4.0 | 4.5 | Local validation, package request/response envelopes, fake and live-shaped compiler fixtures | Add optional credentialed Markitect compiler adapter and schema drift suite. | +| Kontextual/Infospace interop | 3.7 | 4.5 | Delegation envelope, fake and live-shaped runtime registry, activation quality report fixture, adapter compatibility manifests | Add credentialed Kontextual adapter drill and broader Infospace restart reports. | +| Testing and evaluation | 4.3 | 4.7 | Deterministic tests over runtime, CLI, adapters, policy, activation, lifecycle, service, fakes, live-shaped packs, API snapshots, and evaluation threshold reports | Add larger regression corpus and threshold trend reports. | +| Service readiness | 4.5 | 4.8 | Service contracts, full local runner parity, framework-neutral service binding, WSGI adapter, health/readiness, config, adapter conformance | Add deployable packaging and operator readiness runbooks. | +| Developer experience | 4.3 | 4.7 | README, package map, CLI examples, persistence/policy/interop/service/lifecycle/fake-pack docs, operational recipe, API compatibility docs | Add troubleshooting matrix and release note templates. | ## Assessment The project has crossed the local integration-readiness threshold. The runtime envelopes, policy/review model, profile-derived configuration, lifecycle rules, -local persistence diagnostics, queryable audit path, fake external pack -manifests, and conformance helpers form a solid integration boundary. +local persistence migrations, queryable/exportable audit path, fake and +live-shaped external pack manifests, service binding, API snapshots, and +conformance helpers form a solid integration boundary. The biggest optimization opportunity is now the next operational layer: -turning diagnostic-only durability into operator actions, adding optional -deployable service bindings, and testing live or live-shaped adapters behind -the same conformance suite as the fake pack. +moving from live-shaped local fixtures to credentialed live adapter drills, +packaging the service binding for deployment, and growing evaluation thresholds +into trend reports. ## Completed Refinement Workplan @@ -86,19 +86,31 @@ the same conformance suite as the fake pack. - adapter pack manifests and explicit missing-capability diagnostics; - an operational end-to-end recipe. +`PMEM-WP-0012` moved the score from 4.0 to 4.2 by adding: + +- framework-neutral `ServiceBinding` and WSGI adapter tests without starting a + listener; +- executable local-store migration planning/apply behavior with audit traces; +- live-shaped Markitect/Kontextual/telemetry adapter fixtures behind the same + manifest and conformance contract; +- audit retention plans and export batches; +- evaluation threshold reports over the scenario corpus; +- public API and service operation compatibility snapshots. + ## Recommended Next Refinement -Create and execute `PMEM-WP-0012`: live-adapter and service-binding readiness. +Create and execute `PMEM-WP-0013`: credentialed adapter drills and deployment +packaging. Highest-value tasks: -- Add an optional framework binding around `LocalServiceRunner` with health and - readiness endpoints. -- Add executable local-store migrations, not only diagnostics. -- Add live-shaped Markitect/Kontextual adapter fixtures behind the manifest and - conformance suite. -- Add audit retention enforcement and telemetry export drills. -- Grow the evaluation corpus into threshold reports that can catch regressions. +- Add optional credentialed Markitect/Kontextual adapter smoke drills that are + skipped unless credentials are present. +- Package the service binding as a deployable local service with operator + readiness checks. +- Add audit retention pruning and telemetry export enforcement. +- Grow evaluation reporting into historical threshold trends. +- Add release note and migration-note templates for compatibility changes. ## Score Movement Gates @@ -111,10 +123,10 @@ Achieved overall score **4.0** when: Move overall score to **4.3+** when: -- Live optional Markitect or Kontextual adapter can be used behind the same - conformance suite as the fake pack. -- Operational docs include a deployable service binding or a clear embedding - recipe. +- Credentialed optional Markitect or Kontextual adapter smoke drills run behind + the same conformance suite as the fake/live-shaped packs. +- Operational docs include deployable service packaging and an operator + readiness runbook. Move overall score to **4.7+** only when: diff --git a/docs/operational-readiness.md b/docs/operational-readiness.md index 0a72c67..e864fd4 100644 --- a/docs/operational-readiness.md +++ b/docs/operational-readiness.md @@ -1,6 +1,6 @@ # Operational Readiness Recipe -Updated: 2026-05-18 +Updated: 2026-05-19 This recipe exercises the local operational surface without requiring live Markitect, Kontextual, or telemetry services. It is the expected smoke path for @@ -60,6 +60,30 @@ Expected checks: audit sink retention mode. - `health["ok"]` is true. +## Service Binding Drill + +`ServiceBinding` wraps `LocalServiceRunner` in an HTTP-shaped API without +starting a listener: + +```python +from phase_memory import ServiceBinding + +binding = ServiceBinding(runner) + +health_response = binding.route("GET", "/health") +ready_response = binding.route("GET", "/ready") +contracts_response = binding.route("GET", "/contracts") +package_response = binding.route( + "POST", + "/operations/package.compile", + {"selection": activation["data"]["activation_plan"]["selection"]}, +) +``` + +The WSGI adapter returned by `binding.as_wsgi_app()` is also callable in tests +without opening a socket. Use this for deployment wrappers so the core service +operation contract stays framework-neutral. + ## Review-Gated Apply Lifecycle actions that require review are denied until an approval marker or @@ -90,6 +114,12 @@ from phase_memory import RuntimeConfig, LocalServiceRunner config = RuntimeConfig.from_profile(profile, local_store_path=".phase-memory-local") runner = LocalServiceRunner(config=config) repair = runner.runtime.repair_diagnostics(source_ref=config.local_store_path) +migration_plan = runner.runtime.plan_store_migration(source_ref=config.local_store_path) +migration_apply = runner.runtime.apply_store_migration( + migration_plan["data"]["migration_plan"], + actor="operator", + source_ref=config.local_store_path, +) ``` Repair diagnostics distinguish: @@ -100,6 +130,22 @@ Repair diagnostics distinguish: - `missing_edge_source` / `missing_edge_target` for graph reference damage. - `orphaned_path_event` when paths reference absent event-log records. +Migration apply is audited as `store.migration.apply` and updates metadata +atomically when changes are needed. + +## Audit Export And Retention Drill + +Runtime audit behavior is inspectable beyond point queries: + +```python +export = runner.runtime.export_audit_events({"operation": "package.compile"}) +retention = runner.runtime.audit_retention_plan(retention_days=30) +``` + +The export batch includes matching audit events and sink retention metadata. +The retention plan identifies eligible operation ids but does not prune records; +retention apply is a follow-on operational task. + ## Adapter Pack Compatibility Fake and future live adapter packs should publish a manifest with: @@ -108,13 +154,16 @@ Fake and future live adapter packs should publish a manifest with: - ownership boundaries for every adapter; - required conformance helpers. -Validate a pack before wiring it into the runtime: +Validate fake or live-shaped packs before wiring them into the runtime: ```python -from phase_memory import fake_external_adapter_pack, validate_adapter_pack_manifest +from phase_memory import fake_external_adapter_pack, live_shaped_adapter_pack, validate_adapter_pack_manifest diagnostics = validate_adapter_pack_manifest(fake_external_adapter_pack()) assert diagnostics == () + +live_diagnostics = validate_adapter_pack_manifest(live_shaped_adapter_pack()) +assert live_diagnostics == () ``` Missing capabilities are reported as `missing_adapter_capability` diagnostics @@ -129,8 +178,12 @@ The stable embedding surface is: `service_contracts()["operations"]`. - `RuntimeConfig` and `resolve_runtime_adapters` for local/external adapter resolution. +- `ServiceBinding` and `service_binding_from_config` for optional service + wrappers. - Adapter conformance helpers in `phase_memory.service`. - External adapter pack manifests and validation helpers. +- Public export and service operation snapshots in + `tests/fixtures/public-api-snapshot.json`. New public operations should be added to the service contract first, then to the local runner, runtime tests, and docs in the same change. diff --git a/src/phase_memory/__init__.py b/src/phase_memory/__init__.py index 5653b0e..e12399d 100644 --- a/src/phase_memory/__init__.py +++ b/src/phase_memory/__init__.py @@ -10,6 +10,7 @@ from .bridge import ( package_response_envelope, ) from .contracts import graph_from_markitect, profile_from_markitect +from .evaluation import EVALUATION_REPORT_SCHEMA, evaluation_threshold_report from .external_adapters import ( ADAPTER_PACK_MANIFEST_SCHEMA, ExternalAdapterPack, @@ -20,9 +21,17 @@ from .external_adapters import ( FakeKontextualRuntimeRegistry, FakeMarkitectPackageCompiler, FakeTelemetryAuditSink, + LiveShapedKontextualEventLog, + LiveShapedKontextualGraphStore, + LiveShapedKontextualRuntimeRegistry, + LiveShapedMarkitectPackageCompiler, + LiveShapedPermissionSemanticIndex, + LiveShapedPolicyGateway, + LiveShapedTelemetryAuditSink, adapter_pack_manifest, fake_external_adapter_pack, fake_external_runtime_config, + live_shaped_adapter_pack, validate_adapter_pack_manifest, ) from .lifecycle import ( @@ -67,6 +76,7 @@ from .retrieval import ( select_event_path, ) from .service import LocalServiceRunner, RuntimeAdapterBundle, RuntimeConfig, health_report, resolve_runtime_adapters, runtime_from_config, service_contracts +from .service_binding import READINESS_REPORT_SCHEMA, SERVICE_BINDING_SCHEMA, ServiceBinding, ServiceResponse, service_binding_from_config from .planner import plan_profile_execution from .runtime import PhaseMemoryRuntime @@ -75,6 +85,7 @@ __all__ = [ "ADAPTER_PACK_MANIFEST_SCHEMA", "Diagnostic", "ExternalAdapterPack", + "EVALUATION_REPORT_SCHEMA", "FakeExternalEventLog", "FakeExternalGraphStore", "FakeExternalPolicyGateway", @@ -82,6 +93,13 @@ __all__ = [ "FakeKontextualRuntimeRegistry", "FakeMarkitectPackageCompiler", "FakeTelemetryAuditSink", + "LiveShapedKontextualEventLog", + "LiveShapedKontextualGraphStore", + "LiveShapedKontextualRuntimeRegistry", + "LiveShapedMarkitectPackageCompiler", + "LiveShapedPermissionSemanticIndex", + "LiveShapedPolicyGateway", + "LiveShapedTelemetryAuditSink", "LifecycleAction", "LifecycleActionKind", "LifecycleState", @@ -113,6 +131,7 @@ __all__ = [ "compact_path", "create_path", "graph_from_markitect", + "evaluation_threshold_report", "merge_path", "make_review_record", "plan_activation", @@ -127,6 +146,7 @@ __all__ = [ "profile_from_markitect", "fake_external_adapter_pack", "fake_external_runtime_config", + "live_shaped_adapter_pack", "adapter_pack_manifest", "validate_adapter_pack_manifest", "path_event", @@ -140,9 +160,14 @@ __all__ = [ "RuntimeConfig", "LocalServiceRunner", "RuntimeAdapterBundle", + "READINESS_REPORT_SCHEMA", + "SERVICE_BINDING_SCHEMA", + "ServiceBinding", + "ServiceResponse", "health_report", "resolve_runtime_adapters", "runtime_from_config", + "service_binding_from_config", "service_contracts", ] diff --git a/src/phase_memory/adapters.py b/src/phase_memory/adapters.py index e74b198..a60e9e4 100644 --- a/src/phase_memory/adapters.py +++ b/src/phase_memory/adapters.py @@ -3,13 +3,19 @@ from __future__ import annotations import json +from datetime import datetime, timezone from pathlib import Path from typing import Any from .models import Diagnostic, MemoryEdge, MemoryEvent, MemoryGraph, MemoryNode, MemoryPath, PolicyDecision, ProfileIntent +from .utils import parse_iso_datetime, stable_digest, utc_now_iso LOCAL_STORE_SCHEMA = "phase_memory.local_store.v1" LOCAL_STORE_METADATA_FILE = "phase-memory.json" +LOCAL_STORE_MIGRATION_PLAN_SCHEMA = "phase_memory.local_store.migration_plan.v1" +LOCAL_STORE_MIGRATION_RESULT_SCHEMA = "phase_memory.local_store.migration_result.v1" +AUDIT_EXPORT_BATCH_SCHEMA = "phase_memory.audit.export_batch.v1" +AUDIT_RETENTION_PLAN_SCHEMA = "phase_memory.audit.retention_plan.v1" class InMemoryMemoryGraphStore: @@ -145,6 +151,109 @@ class FileBackedMemoryGraphStore: def metadata(self) -> dict[str, Any]: return _read_json(self.root / LOCAL_STORE_METADATA_FILE) + def migration_plan(self) -> dict[str, Any]: + metadata_path = self.root / LOCAL_STORE_METADATA_FILE + diagnostics = list(self.metadata_diagnostics()) + metadata: dict[str, Any] = {} + schema_version = "" + + if metadata_path.exists(): + try: + metadata = _read_json(metadata_path) + schema_version = str(metadata.get("schema_version") or "") + except json.JSONDecodeError: + pass + + actions: list[dict[str, Any]] = [] + if not any(diagnostic.code == "corrupt_store_metadata" for diagnostic in diagnostics): + if schema_version != LOCAL_STORE_SCHEMA: + actions.append( + { + "id": "set_schema_version", + "action": "set_schema_version", + "from_schema_version": schema_version, + "to_schema_version": LOCAL_STORE_SCHEMA, + } + ) + planned = metadata.get("planned_migrations") or metadata.get("migrations") or () + for item in planned: + migration_id = str(item) + actions.append( + { + "id": f"complete_planned:{migration_id}", + "action": "complete_planned_migration", + "migration": migration_id, + } + ) + + plan_id = f"store-migration:{stable_digest([str(self.root), schema_version, actions])}" + return { + "schema_version": LOCAL_STORE_MIGRATION_PLAN_SCHEMA, + "id": plan_id, + "store_path": str(self.root), + "metadata_path": str(metadata_path), + "current_schema_version": schema_version, + "target_schema_version": LOCAL_STORE_SCHEMA, + "valid": not any(diagnostic.severity == "error" for diagnostic in diagnostics), + "dry_run": True, + "actions": actions, + "diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics], + } + + def apply_migration_plan(self, plan: dict[str, Any] | None = None, *, actor: str = "local") -> dict[str, Any]: + plan = dict(plan or self.migration_plan()) + diagnostics = [dict(item) for item in plan.get("diagnostics", ())] + errors = [item for item in diagnostics if item.get("severity") == "error"] + if errors: + return { + "schema_version": LOCAL_STORE_MIGRATION_RESULT_SCHEMA, + "plan_id": plan.get("id", ""), + "store_path": str(self.root), + "applied": False, + "changed": False, + "actions": [], + "diagnostics": diagnostics, + } + + metadata_path = self.root / LOCAL_STORE_METADATA_FILE + try: + metadata = _read_json(metadata_path) if metadata_path.exists() else {} + except json.JSONDecodeError: + metadata = {} + + actions = [dict(item) for item in plan.get("actions", ())] + completed = list(metadata.get("completed_migrations") or ()) + for action in actions: + if action.get("action") == "set_schema_version": + metadata["schema_version"] = LOCAL_STORE_SCHEMA + if action.get("action") == "complete_planned_migration": + completed.append(str(action.get("migration") or "")) + + if actions: + metadata["schema_version"] = LOCAL_STORE_SCHEMA + metadata["migrations"] = [] + metadata.pop("planned_migrations", None) + if completed: + metadata["completed_migrations"] = sorted({item for item in completed if item}) + metadata["last_migration"] = { + "plan_id": str(plan.get("id") or ""), + "actor": actor, + "applied_at": utc_now_iso(), + "actions": [str(action.get("id") or "") for action in actions], + } + _write_json(metadata_path, metadata) + + return { + "schema_version": LOCAL_STORE_MIGRATION_RESULT_SCHEMA, + "plan_id": plan.get("id", ""), + "store_path": str(self.root), + "applied": True, + "changed": bool(actions), + "actions": actions, + "metadata": metadata, + "diagnostics": diagnostics, + } + def repair_diagnostics(self, *, events: list[MemoryEvent] | None = None) -> tuple[Diagnostic, ...]: diagnostics: list[Diagnostic] = [] nodes, node_diagnostics = _read_records(self.nodes_dir, MemoryNode.from_mapping, record_type="node") @@ -323,6 +432,13 @@ class RecordingAuditSink: def retention_metadata(self) -> dict[str, Any]: return {"mode": "in_memory", "retention_days": None} + def retention_plan(self, *, retention_days: int | None = None, now: datetime | None = None) -> dict[str, Any]: + return audit_retention_plan(self.events, retention_days=retention_days, now=now, retention=self.retention_metadata()) + + def export_batch(self, **filters: Any) -> dict[str, Any]: + events = self.query(**filters) + return audit_export_batch(events, filters=filters, retention=self.retention_metadata()) + class JsonlAuditSink: def __init__(self, path: str | Path) -> None: @@ -352,6 +468,13 @@ class JsonlAuditSink: def retention_metadata(self) -> dict[str, Any]: return {"mode": "jsonl", "path": str(self.path), "retention_days": None} + def retention_plan(self, *, retention_days: int | None = None, now: datetime | None = None) -> dict[str, Any]: + return audit_retention_plan(self.query(), retention_days=retention_days, now=now, retention=self.retention_metadata()) + + def export_batch(self, **filters: Any) -> dict[str, Any]: + events = self.query(**filters) + return audit_export_batch(events, filters=filters, retention=self.retention_metadata()) + class InMemorySemanticIndex: def __init__(self) -> None: @@ -429,6 +552,54 @@ def filter_audit_events(events: list[dict[str, Any]], **filters: Any) -> list[di return [dict(event) for event in events if _audit_event_matches(event, filters)] +def audit_export_batch( + events: list[dict[str, Any]], + *, + filters: dict[str, Any] | None = None, + retention: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "schema_version": AUDIT_EXPORT_BATCH_SCHEMA, + "id": f"audit-export:{stable_digest([filters or {}, events])}", + "filters": dict(filters or {}), + "count": len(events), + "events": [dict(event) for event in events], + "retention": dict(retention or {}), + } + + +def audit_retention_plan( + events: list[dict[str, Any]], + *, + retention_days: int | None = None, + now: datetime | None = None, + retention: dict[str, Any] | None = None, +) -> dict[str, Any]: + retention = dict(retention or {}) + if retention_days is None: + retention_days = retention.get("retention_days") + now = now or datetime.now(timezone.utc) + eligible: list[str] = [] + retained: list[str] = [] + for event in events: + event_id = str(event.get("operation_id") or event.get("id") or stable_digest(event)) + age = _event_age_days(event, now=now) + if retention_days is not None and age is not None and age >= int(retention_days): + eligible.append(event_id) + else: + retained.append(event_id) + return { + "schema_version": AUDIT_RETENTION_PLAN_SCHEMA, + "id": f"audit-retention:{stable_digest([retention_days, eligible, retained])}", + "retention_days": retention_days, + "eligible_count": len(eligible), + "retained_count": len(retained), + "eligible_operation_ids": eligible, + "retained_operation_ids": retained, + "retention": retention, + } + + def _audit_event_matches(event: dict[str, Any], filters: dict[str, Any]) -> bool: operation = filters.get("operation") if operation is not None and event.get("operation") != operation: @@ -455,3 +626,10 @@ def _audit_event_matches(event: dict[str, Any], filters: dict[str, Any]) -> bool if allowed is not None and bool(event.get("allowed")) is not bool(allowed): return False return True + + +def _event_age_days(event: dict[str, Any], *, now: datetime) -> int | None: + timestamp = parse_iso_datetime(str(event.get("timestamp") or "")) + if timestamp is None: + return None + return max((now - timestamp).days, 0) diff --git a/src/phase_memory/evaluation.py b/src/phase_memory/evaluation.py new file mode 100644 index 0000000..bbdea40 --- /dev/null +++ b/src/phase_memory/evaluation.py @@ -0,0 +1,161 @@ +"""Evaluation threshold reports for deterministic scenario fixtures.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from .adapters import InMemorySemanticIndex +from .contracts import graph_from_markitect +from .models import Diagnostic, MemoryPath +from .retrieval import activation_quality_report, select_event_path +from .runtime import PhaseMemoryRuntime + +EVALUATION_REPORT_SCHEMA = "phase_memory.evaluation.threshold_report.v1" + +DEFAULT_THRESHOLDS = { + "policy_denial_count": 1, + "lifecycle_action_count": 3, + "path_event_count": 1, + "semantic_hit_count": 1, + "budget_omission_count": 1, + "source_span_coverage": 1.0, + "explanation_coverage": 1.0, +} + + +def evaluation_threshold_report(data: dict[str, Any], *, thresholds: dict[str, float] | None = None) -> dict[str, Any]: + thresholds = {**DEFAULT_THRESHOLDS, **dict(thresholds or {})} + scenarios = list(data.get("scenarios") or ()) + metrics = { + "scenario_count": len(scenarios), + "policy_denial_count": 0, + "lifecycle_action_count": 0, + "path_event_count": 0, + "semantic_hit_count": 0, + "budget_omission_count": 0, + "source_span_coverage": 0.0, + "explanation_coverage": 0.0, + } + scenario_reports: list[dict[str, Any]] = [] + + for scenario in scenarios: + scenario_id = str(scenario.get("id") or "") + if scenario_id == "policy-denied-activation": + report = _policy_scenario(scenario) + elif scenario_id == "profile-lifecycle-rules": + report = _lifecycle_scenario(scenario) + elif scenario_id == "budget-path-and-semantic-hints": + report = _budget_scenario(scenario) + else: + report = {"id": scenario_id, "metrics": {}, "diagnostics": [{"severity": "warn", "code": "unknown_scenario", "message": "Scenario is not recognized by this report."}]} + scenario_reports.append(report) + for key, value in report.get("metrics", {}).items(): + if key in metrics and isinstance(value, (int, float)): + metrics[key] += value + + diagnostics = _threshold_diagnostics(metrics, thresholds) + return { + "schema_version": EVALUATION_REPORT_SCHEMA, + "valid": not diagnostics, + "metrics": metrics, + "thresholds": thresholds, + "scenarios": scenario_reports, + "diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics], + } + + +def _policy_scenario(scenario: dict[str, Any]) -> dict[str, Any]: + runtime = PhaseMemoryRuntime() + response = runtime.plan_activation( + scenario["graph"], + max_items=int(scenario["profile"].get("activation", {}).get("max_items") or 4), + max_tokens=int(scenario["profile"].get("activation", {}).get("max_tokens") or 60), + profile_id=scenario["profile"]["id"], + policy_context={"denied_labels": ["restricted"], "secrets_allowed": False, "trust_zone": "local"}, + ) + denials = response["data"]["policy_denials"] + return { + "id": scenario["id"], + "metrics": {"policy_denial_count": len(denials)}, + "diagnostics": response["diagnostics"], + } + + +def _lifecycle_scenario(scenario: dict[str, Any]) -> dict[str, Any]: + runtime = PhaseMemoryRuntime() + response = runtime.plan_lifecycle_with_profile( + scenario["profile"], + scenario["graph"], + refresh_digests={"life.decision": "decision-new"}, + now=datetime(2026, 5, 18, tzinfo=timezone.utc), + ) + return { + "id": scenario["id"], + "metrics": {"lifecycle_action_count": len(response["data"]["dry_run_actions"])}, + "diagnostics": response["diagnostics"], + } + + +def _budget_scenario(scenario: dict[str, Any]) -> dict[str, Any]: + runtime = PhaseMemoryRuntime() + graph = graph_from_markitect(scenario["graph"]).value + activation = runtime.plan_activation( + scenario["graph"], + max_items=int(scenario["profile"]["activation"]["max_items"]), + max_tokens=int(scenario["profile"]["activation"]["max_tokens"]), + profile_id=scenario["profile"]["id"], + priority_node_ids=tuple(scenario["expect"]["selected_node_ids"]), + ) + plan = activation["data"]["activation_plan"] + quality = activation_quality_report(_activation_plan_from_response(activation), expected_node_ids=tuple(scenario["expect"]["selected_node_ids"])) + path_events = select_event_path(graph.events, MemoryPath.from_mapping(scenario["path"]), max_events=2) + index = InMemorySemanticIndex() + index.upsert_nodes(list(graph.nodes)) + semantic_hits = index.query(graph_id=graph.graph_id, query="semantic restart", limit=2) + return { + "id": scenario["id"], + "metrics": { + "path_event_count": len(path_events), + "semantic_hit_count": 1 if semantic_hits and semantic_hits[0]["id"] == scenario["expect"]["semantic_top_id"] else 0, + "budget_omission_count": len(plan["omitted"]), + "source_span_coverage": quality["source_span_coverage"], + "explanation_coverage": quality["explanation_coverage"], + }, + "diagnostics": activation["diagnostics"], + } + + +def _activation_plan_from_response(response: dict[str, Any]): + from .models import ActivationPlan + + data = response["data"]["activation_plan"] + return ActivationPlan( + plan_id=data["plan_id"], + graph_id=data["graph_id"], + selected_node_ids=tuple(data["selected_node_ids"]), + selected_event_ids=tuple(data["selected_event_ids"]), + omitted=tuple(data["omitted"]), + token_estimate=data["token_estimate"], + max_items=data["max_items"], + max_tokens=data["max_tokens"], + selection=response["data"]["package_request"]["selection"], + diagnostics=(), + ) + + +def _threshold_diagnostics(metrics: dict[str, Any], thresholds: dict[str, float]) -> tuple[Diagnostic, ...]: + diagnostics: list[Diagnostic] = [] + for key, threshold in sorted(thresholds.items()): + actual = float(metrics.get(key) or 0) + if actual < float(threshold): + diagnostics.append( + Diagnostic( + "error", + "evaluation_threshold_failed", + "Evaluation metric did not meet its threshold.", + key, + {"actual": actual, "threshold": threshold}, + ) + ) + return tuple(diagnostics) diff --git a/src/phase_memory/external_adapters.py b/src/phase_memory/external_adapters.py index f9a255c..c52cff9 100644 --- a/src/phase_memory/external_adapters.py +++ b/src/phase_memory/external_adapters.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any -from .adapters import InMemoryMemoryEventLog, InMemoryMemoryGraphStore, InMemorySemanticIndex, filter_audit_events +from .adapters import InMemoryMemoryEventLog, InMemoryMemoryGraphStore, InMemorySemanticIndex, audit_export_batch, audit_retention_plan, filter_audit_events from .models import Diagnostic, PolicyDecision from .service import RuntimeConfig from .utils import stable_digest @@ -47,6 +47,7 @@ class ExternalAdapterPack: capabilities: tuple[str, ...] = () ownership_boundaries: dict[str, str] = field(default_factory=dict) required_conformance: dict[str, str] = field(default_factory=dict) + capability_requirements: dict[str, tuple[str, ...]] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: @@ -56,6 +57,7 @@ class ExternalAdapterPack: "capabilities": list(self.capabilities), "ownership_boundaries": dict(self.ownership_boundaries), "required_conformance": dict(self.required_conformance), + "capability_requirements": {key: list(value) for key, value in sorted(self.capability_requirements.items())}, "metadata": dict(self.metadata), } @@ -137,6 +139,13 @@ class FakeTelemetryAuditSink: "retention_days": self.retention_days, } + def retention_plan(self, *, retention_days: int | None = None, now=None) -> dict[str, Any]: + return audit_retention_plan(self.events, retention_days=retention_days, now=now, retention=self.retention_metadata()) + + def export_batch(self, **filters: Any) -> dict[str, Any]: + events = self.query(**filters) + return audit_export_batch(events, filters=filters, retention=self.retention_metadata()) + class FakeKontextualRuntimeRegistry: def __init__(self) -> None: @@ -192,6 +201,99 @@ def fake_external_adapter_pack() -> ExternalAdapterPack: ) +class LiveShapedKontextualGraphStore(FakeExternalGraphStore): + """Live-shaped Kontextual graph store fixture with deterministic behavior.""" + + +class LiveShapedKontextualEventLog(FakeExternalEventLog): + """Live-shaped Kontextual event log fixture with deterministic behavior.""" + + +class LiveShapedPermissionSemanticIndex(FakeExternalSemanticIndex): + """Live-shaped retrieval fixture that keeps tests local and deterministic.""" + + +class LiveShapedMarkitectPackageCompiler(FakeMarkitectPackageCompiler): + def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]: + response = super().compile_selection(selection) + package_ref = f"markitect-live-shaped:{stable_digest(selection)}" + return { + **response, + "package_id": package_ref, + "package_ref": package_ref, + "compiler": self.__class__.__name__, + "transport": "fixture", + } + + +class LiveShapedPolicyGateway(FakeExternalPolicyGateway): + """Live-shaped policy gateway fixture with explicit deny action support.""" + + +class LiveShapedTelemetryAuditSink(FakeTelemetryAuditSink): + def retention_metadata(self) -> dict[str, Any]: + return { + "mode": "live_shaped_telemetry", + "retention_days": self.retention_days, + "transport": "fixture", + } + + +class LiveShapedKontextualRuntimeRegistry(FakeKontextualRuntimeRegistry): + def publish_runtime_envelope(self, envelope: dict[str, Any]) -> dict[str, Any]: + reference = f"kontextual-live-shaped:{stable_digest(envelope)}" + stored = dict(envelope) + self._envelopes[reference] = stored + return { + "published": True, + "reference": reference, + "adapter": self.__class__.__name__, + "envelope": stored, + } + + +def live_shaped_adapter_pack() -> ExternalAdapterPack: + capability_requirements = { + "graph_store": ("kontextual.graph-store.live-shaped",), + "event_log": ("kontextual.event-log.live-shaped",), + "policy_gateway": ("policy.gateway.live-shaped",), + "audit_sink": ("telemetry.audit.live-shaped",), + "package_compiler": ("markitect.package.compile.live-shaped",), + "semantic_index": ("semantic-index.live-shaped",), + "runtime_registry": ("kontextual.runtime.registry.live-shaped",), + } + ownership_boundaries = { + "graph_store": "kontextual owns durable graph records; live fixture preserves phase-memory graph semantics", + "event_log": "kontextual owns event durability; live fixture preserves phase-memory event shape", + "policy_gateway": "external policy owns decision enforcement; phase-memory owns operation context", + "audit_sink": "external telemetry owns retention/export; phase-memory owns audit event schema", + "package_compiler": "markitect owns package compilation; phase-memory owns activation selection", + "semantic_index": "external retrieval owns index ranking; phase-memory owns policy-filtered activation", + "runtime_registry": "kontextual owns registry transport; phase-memory owns runtime envelope contract", + } + return ExternalAdapterPack( + name="live-shaped", + adapters={ + "graph_store": LiveShapedKontextualGraphStore(), + "event_log": LiveShapedKontextualEventLog(), + "policy_gateway": LiveShapedPolicyGateway(), + "audit_sink": LiveShapedTelemetryAuditSink(retention_days=90), + "package_compiler": LiveShapedMarkitectPackageCompiler(), + "semantic_index": LiveShapedPermissionSemanticIndex(), + "runtime_registry": LiveShapedKontextualRuntimeRegistry(), + }, + capabilities=tuple(sorted({capability for values in capability_requirements.values() for capability in values})), + ownership_boundaries=ownership_boundaries, + required_conformance=dict(ADAPTER_CONFORMANCE_HELPERS), + capability_requirements=capability_requirements, + metadata={ + "intended_for": "local live-shaped compatibility tests", + "requires_credentials": False, + "network_required": False, + }, + ) + + def fake_external_runtime_config() -> RuntimeConfig: return RuntimeConfig( adapter_registry={ @@ -213,6 +315,7 @@ def fake_external_runtime_config() -> RuntimeConfig: def adapter_pack_manifest(pack: ExternalAdapterPack) -> dict[str, Any]: + capability_requirements = _capability_requirements(pack) return { "schema_version": ADAPTER_PACK_MANIFEST_SCHEMA, "name": pack.name, @@ -222,7 +325,7 @@ def adapter_pack_manifest(pack: ExternalAdapterPack) -> dict[str, Any]: key: { "class": pack.adapters[key].__class__.__name__, "ownership": pack.ownership_boundaries.get(key, ""), - "required_capabilities": list(ADAPTER_REQUIRED_CAPABILITIES.get(key, ())), + "required_capabilities": list(capability_requirements.get(key, ())), "required_conformance": pack.required_conformance.get(key, ADAPTER_CONFORMANCE_HELPERS.get(key, "")), } for key in sorted(pack.adapters) @@ -233,6 +336,7 @@ def adapter_pack_manifest(pack: ExternalAdapterPack) -> dict[str, Any]: def validate_adapter_pack_manifest(pack: ExternalAdapterPack) -> tuple[Diagnostic, ...]: diagnostics: list[Diagnostic] = [] capabilities = set(pack.capabilities) + capability_requirements = _capability_requirements(pack) for adapter in ADAPTER_PACK_REQUIRED_ADAPTERS: if adapter not in pack.adapters: diagnostics.append( @@ -265,7 +369,7 @@ def validate_adapter_pack_manifest(pack: ExternalAdapterPack) -> tuple[Diagnosti {"adapter": adapter}, ) ) - for capability in ADAPTER_REQUIRED_CAPABILITIES.get(adapter, ()): + for capability in capability_requirements.get(adapter, ()): if capability not in capabilities: diagnostics.append( Diagnostic( @@ -277,3 +381,9 @@ def validate_adapter_pack_manifest(pack: ExternalAdapterPack) -> tuple[Diagnosti ) ) return tuple(diagnostics) + + +def _capability_requirements(pack: ExternalAdapterPack) -> dict[str, tuple[str, ...]]: + if pack.capability_requirements: + return {key: tuple(value) for key, value in pack.capability_requirements.items()} + return dict(ADAPTER_REQUIRED_CAPABILITIES) diff --git a/src/phase_memory/policy.py b/src/phase_memory/policy.py index 796ab2b..7af6797 100644 --- a/src/phase_memory/policy.py +++ b/src/phase_memory/policy.py @@ -22,6 +22,8 @@ class MemoryOperation(str, Enum): ACTIVATION_PLAN = "graph.activation.plan" PACKAGE_COMPILE = "package.compile" AUDIT_QUERY = "audit.query" + AUDIT_EXPORT = "audit.export" + AUDIT_RETENTION_PLAN = "audit.retention.plan" LIFECYCLE_APPLY = "lifecycle.apply" STABILIZATION = "memory.stabilize" COMPACTION = "memory.compact" @@ -30,6 +32,8 @@ class MemoryOperation(str, Enum): ARCHIVE = "memory.archive" GRAPH_EXPORT = "graph.export" STORE_REPAIR = "store.repair.diagnostics" + STORE_MIGRATION_PLAN = "store.migration.plan" + STORE_MIGRATION_APPLY = "store.migration.apply" POLICY_OPERATION_POINTS = tuple(operation.value for operation in MemoryOperation) diff --git a/src/phase_memory/runtime.py b/src/phase_memory/runtime.py index 927e298..1fb062f 100644 --- a/src/phase_memory/runtime.py +++ b/src/phase_memory/runtime.py @@ -38,6 +38,8 @@ from .utils import compact_dict, stable_digest, to_plain RUNTIME_ENVELOPE_SCHEMA = "phase_memory.runtime.envelope.v1" AUDIT_QUERY_SCHEMA = "phase_memory.audit.query.v1" +AUDIT_EXPORT_SCHEMA = "phase_memory.audit.export.v1" +AUDIT_RETENTION_SCHEMA = "phase_memory.audit.retention.v1" PACKAGE_REQUEST_SCHEMA = MARKITECT_PACKAGE_REQUEST_SCHEMA @@ -300,6 +302,103 @@ class PhaseMemoryRuntime: "source": {"ref": source_ref}, } + def export_audit_events(self, filters: dict[str, Any] | None = None, *, source_ref: str = "audit") -> dict[str, Any]: + filters = _audit_filters(filters or {}) + policy = self.policy_gateway.authorize( + action="audit.export", + resource="audit:events", + context={"source_ref": source_ref, "dry_run": True, "filters": filters}, + ) + if policy.allowed and hasattr(self.audit_sink, "export_batch"): + batch = self.audit_sink.export_batch(**filters) + diagnostics: tuple[Diagnostic, ...] = () + elif policy.allowed: + events, diagnostics = _query_audit_sink(self.audit_sink, filters) + batch = { + "schema_version": "phase_memory.audit.export_batch.v1", + "filters": filters, + "count": len(events), + "events": events, + "retention": _audit_retention_metadata(self.audit_sink), + } + else: + diagnostics = () + batch = {"filters": filters, "count": 0, "events": [], "retention": _audit_retention_metadata(self.audit_sink)} + operation_id = f"op:{stable_digest(['audit.export', source_ref, filters])}" + audit = self.audit_sink.record( + audit_event( + operation_id=operation_id, + operation="audit.export", + subject={"kind": "audit_events", "id": stable_digest(filters)}, + policy_decision=policy, + dry_run=True, + source_ref=source_ref, + ) + ) + return { + "schema_version": AUDIT_EXPORT_SCHEMA, + "operation_id": operation_id, + "operation": "audit.export", + "dry_run": True, + "valid": policy.allowed and not any(diagnostic.severity == "error" for diagnostic in diagnostics), + "batch": batch, + "policy_decision": _policy_to_dict(policy), + "audit_receipt": audit, + "diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics], + "source": {"ref": source_ref}, + } + + def audit_retention_plan( + self, + *, + retention_days: int | None = None, + now: datetime | None = None, + source_ref: str = "audit", + ) -> dict[str, Any]: + policy = self.policy_gateway.authorize( + action="audit.retention.plan", + resource="audit:events", + context={"source_ref": source_ref, "dry_run": True, "retention_days": retention_days}, + ) + diagnostics: tuple[Diagnostic, ...] = () + if policy.allowed and hasattr(self.audit_sink, "retention_plan"): + plan = self.audit_sink.retention_plan(retention_days=retention_days, now=now) + elif policy.allowed: + plan = {} + diagnostics = ( + Diagnostic( + "error", + "audit_retention_plan_unsupported", + "Audit sink does not expose retention planning.", + self.audit_sink.__class__.__name__, + ), + ) + else: + plan = {} + operation_id = f"op:{stable_digest(['audit.retention.plan', source_ref, retention_days, plan])}" + audit = self.audit_sink.record( + audit_event( + operation_id=operation_id, + operation="audit.retention.plan", + subject={"kind": "audit_events", "id": "retention"}, + policy_decision=policy, + dry_run=True, + source_ref=source_ref, + ) + ) + return { + "schema_version": AUDIT_RETENTION_SCHEMA, + "operation_id": operation_id, + "operation": "audit.retention.plan", + "dry_run": True, + "valid": policy.allowed and not any(diagnostic.severity == "error" for diagnostic in diagnostics), + "plan": plan, + "policy_decision": _policy_to_dict(policy), + "audit_receipt": audit, + "diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics], + "source": {"ref": source_ref}, + } + 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"): @@ -335,6 +434,63 @@ class PhaseMemoryRuntime: data={"diagnostic_count": len(diagnostics)}, ) + def plan_store_migration(self, *, source_ref: str = "local-store") -> dict[str, Any]: + if hasattr(self.graph_store, "migration_plan"): + plan = self.graph_store.migration_plan() + diagnostics = _diagnostics_from_dicts(plan.get("diagnostics", ())) + else: + plan = {} + diagnostics = ( + Diagnostic( + "error", + "store_migration_unsupported", + "Graph store does not expose local migration planning.", + self.graph_store.__class__.__name__, + ), + ) + return self._envelope( + "store.migration.plan", + 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={"migration_plan": plan}, + ) + + def apply_store_migration( + self, + migration_plan: dict[str, Any] | None = None, + *, + actor: str = "local", + source_ref: str = "local-store", + ) -> dict[str, Any]: + if hasattr(self.graph_store, "apply_migration_plan"): + result = self.graph_store.apply_migration_plan(migration_plan, actor=actor) + diagnostics = _diagnostics_from_dicts(result.get("diagnostics", ())) + valid = bool(result.get("applied")) and not any(diagnostic.severity == "error" for diagnostic in diagnostics) + else: + result = {} + diagnostics = ( + Diagnostic( + "error", + "store_migration_unsupported", + "Graph store does not expose local migration apply.", + self.graph_store.__class__.__name__, + ), + ) + valid = False + return self._envelope( + "store.migration.apply", + subject_kind="local_store", + subject_id=source_ref, + valid=valid, + diagnostics=diagnostics, + source_ref=source_ref, + dry_run=False, + data={"migration_result": result}, + ) + def apply_lifecycle_actions( self, actions: Iterable[LifecycleAction | dict[str, Any]], @@ -487,6 +643,21 @@ def _policy_to_dict(decision: PolicyDecision) -> dict[str, Any]: return decision.to_dict() if hasattr(decision, "to_dict") else to_plain(decision) +def _diagnostics_from_dicts(items: Iterable[dict[str, Any]]) -> tuple[Diagnostic, ...]: + diagnostics: list[Diagnostic] = [] + for item in items: + diagnostics.append( + Diagnostic( + str(item.get("severity") or "info"), + str(item.get("code") or "diagnostic"), + str(item.get("message") or ""), + str(item.get("path") or ""), + dict(item.get("metadata") or {}), + ) + ) + return tuple(diagnostics) + + def _audit_filters(filters: dict[str, Any]) -> dict[str, Any]: allowed_keys = { "operation", diff --git a/src/phase_memory/service.py b/src/phase_memory/service.py index 2ede028..88200fb 100644 --- a/src/phase_memory/service.py +++ b/src/phase_memory/service.py @@ -335,6 +335,8 @@ def health_report(runtime: PhaseMemoryRuntime, *, config: RuntimeConfig | None = class LocalServiceRunner: """Minimal optional service runner shape without web framework dependency.""" + SUPPORTED_OPERATIONS = tuple(SERVICE_OPERATIONS) + def __init__( self, runtime: PhaseMemoryRuntime | None = None, @@ -345,6 +347,10 @@ class LocalServiceRunner: self.config = config or RuntimeConfig.local_default() self.runtime = runtime or runtime_from_config(self.config, external_adapters=external_adapters) + @classmethod + def supported_operations(cls) -> tuple[str, ...]: + return cls.SUPPORTED_OPERATIONS + def handle(self, operation: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: payload = payload or {} if operation == "health.check": diff --git a/src/phase_memory/service_binding.py b/src/phase_memory/service_binding.py new file mode 100644 index 0000000..588c6f2 --- /dev/null +++ b/src/phase_memory/service_binding.py @@ -0,0 +1,164 @@ +"""Optional framework-neutral service binding for local embeddings.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from io import BytesIO +from typing import Any, Callable + +from .service import HEALTH_REPORT_SCHEMA, LocalServiceRunner, RuntimeConfig, health_report, service_contracts + +SERVICE_BINDING_SCHEMA = "phase_memory.service.binding.v1" +READINESS_REPORT_SCHEMA = "phase_memory.service.readiness.v1" + + +@dataclass(frozen=True) +class ServiceResponse: + status: int + body: dict[str, Any] + headers: dict[str, str] = field(default_factory=lambda: {"content-type": "application/json"}) + + def to_wsgi(self) -> tuple[str, list[tuple[str, str]], bytes]: + status_text = _status_text(self.status) + payload = json.dumps(self.body, sort_keys=True, separators=(",", ":")).encode("utf-8") + headers = {**self.headers, "content-length": str(len(payload))} + return status_text, list(headers.items()), payload + + +class ServiceBinding: + """HTTP-shaped adapter around ``LocalServiceRunner`` without a server dependency.""" + + def __init__(self, runner: LocalServiceRunner | None = None) -> None: + self.runner = runner or LocalServiceRunner() + + def readiness(self) -> dict[str, Any]: + health = health_report(self.runner.runtime, config=self.runner.config) + contracts = service_contracts() + supported = set(self.runner.supported_operations()) + declared = set(contracts["operations"]) + unsupported = sorted(declared - supported) + extra = sorted(supported - declared) + diagnostics = list(health["diagnostics"]) + if unsupported: + diagnostics.append( + { + "severity": "error", + "code": "unsupported_service_operations", + "message": "Service binding cannot dispatch every declared operation.", + "metadata": {"operations": unsupported}, + } + ) + if extra: + diagnostics.append( + { + "severity": "warn", + "code": "undocumented_service_operations", + "message": "Service binding exposes operations not declared in service contracts.", + "metadata": {"operations": extra}, + } + ) + ok = health["ok"] and not any(item.get("severity") == "error" for item in diagnostics) + return { + "schema_version": READINESS_REPORT_SCHEMA, + "ok": ok, + "health": health, + "contracts": contracts, + "supported_operations": sorted(supported), + "unsupported_operations": unsupported, + "extra_operations": extra, + "diagnostics": diagnostics, + } + + def route(self, method: str, path: str, body: dict[str, Any] | None = None) -> ServiceResponse: + method = method.upper() + path = _normalize_path(path) + body = body or {} + + if method == "GET" and path == "/health": + health = health_report(self.runner.runtime, config=self.runner.config) + return ServiceResponse(200 if health["ok"] else 503, health) + if method == "GET" and path == "/ready": + readiness = self.readiness() + return ServiceResponse(200 if readiness["ok"] else 503, readiness) + if method == "GET" and path == "/contracts": + return ServiceResponse(200, service_contracts()) + if method == "POST" and path == "/operations": + operation = str(body.get("operation") or "") + return self._dispatch(operation, dict(body.get("payload") or {})) + if method == "POST" and path.startswith("/operations/"): + operation = path.removeprefix("/operations/").replace("/", ".") + return self._dispatch(operation, body) + return ServiceResponse( + 404, + { + "schema_version": SERVICE_BINDING_SCHEMA, + "ok": False, + "error": "not_found", + "method": method, + "path": path, + }, + ) + + def as_wsgi_app(self) -> Callable: + def app(environ: dict[str, Any], start_response: Callable) -> list[bytes]: + method = str(environ.get("REQUEST_METHOD") or "GET") + path = str(environ.get("PATH_INFO") or "/") + body = _read_wsgi_json(environ) + response = self.route(method, path, body) + status, headers, payload = response.to_wsgi() + start_response(status, headers) + return [payload] + + return app + + def _dispatch(self, operation: str, payload: dict[str, Any]) -> ServiceResponse: + if operation not in service_contracts()["operations"]: + return ServiceResponse( + 404, + { + "schema_version": SERVICE_BINDING_SCHEMA, + "ok": False, + "error": "unsupported_operation", + "operation": operation, + }, + ) + try: + return ServiceResponse(200, self.runner.handle(operation, payload)) + except (KeyError, TypeError, ValueError) as exc: + return ServiceResponse( + 400, + { + "schema_version": SERVICE_BINDING_SCHEMA, + "ok": False, + "error": "invalid_request", + "operation": operation, + "message": str(exc), + }, + ) + + +def service_binding_from_config(config: RuntimeConfig | None = None) -> ServiceBinding: + return ServiceBinding(LocalServiceRunner(config=config or RuntimeConfig.local_default())) + + +def _normalize_path(path: str) -> str: + normalized = "/" + path.strip("/") + return normalized if normalized != "/" else "/" + + +def _status_text(status: int) -> str: + labels = {200: "OK", 400: "Bad Request", 404: "Not Found", 503: "Service Unavailable"} + return f"{status} {labels.get(status, 'Unknown')}" + + +def _read_wsgi_json(environ: dict[str, Any]) -> dict[str, Any]: + try: + length = int(environ.get("CONTENT_LENGTH") or 0) + except ValueError: + length = 0 + stream = environ.get("wsgi.input") or BytesIO() + raw = stream.read(length) if length else b"" + if not raw: + return {} + return json.loads(raw.decode("utf-8")) diff --git a/tests/fixtures/public-api-snapshot.json b/tests/fixtures/public-api-snapshot.json new file mode 100644 index 0000000..ec0a543 --- /dev/null +++ b/tests/fixtures/public-api-snapshot.json @@ -0,0 +1,102 @@ +{ + "exports": [ + "ADAPTER_PACK_MANIFEST_SCHEMA", + "ActivationPlan", + "Diagnostic", + "EVALUATION_REPORT_SCHEMA", + "ExternalAdapterPack", + "FakeExternalEventLog", + "FakeExternalGraphStore", + "FakeExternalPolicyGateway", + "FakeExternalSemanticIndex", + "FakeKontextualRuntimeRegistry", + "FakeMarkitectPackageCompiler", + "FakeTelemetryAuditSink", + "LifecycleAction", + "LifecycleActionKind", + "LifecycleRuleConfig", + "LifecycleState", + "LiveShapedKontextualEventLog", + "LiveShapedKontextualGraphStore", + "LiveShapedKontextualRuntimeRegistry", + "LiveShapedMarkitectPackageCompiler", + "LiveShapedPermissionSemanticIndex", + "LiveShapedPolicyGateway", + "LiveShapedTelemetryAuditSink", + "LocalMarkitectValidator", + "LocalServiceRunner", + "MARKITECT_PACKAGE_REQUEST_SCHEMA", + "MARKITECT_PACKAGE_RESPONSE_SCHEMA", + "MemoryEdge", + "MemoryEvent", + "MemoryGraph", + "MemoryKind", + "MemoryNode", + "MemoryOperation", + "MemoryPath", + "MemoryPathState", + "MemoryPhase", + "OptionalMarkitectValidator", + "POLICY_OPERATION_POINTS", + "PhaseMemoryRuntime", + "PhaseTransitionRule", + "PolicyDecision", + "ProfileExecutionPlan", + "ProfileIntent", + "READINESS_REPORT_SCHEMA", + "RetentionRule", + "ReviewDecision", + "ReviewRecord", + "RuntimeAdapterBundle", + "RuntimeConfig", + "SERVICE_BINDING_SCHEMA", + "ServiceBinding", + "ServiceResponse", + "WordCountTokenEstimator", + "abandon_path", + "activation_quality_report", + "adapter_pack_manifest", + "branch_path", + "compact_path", + "create_path", + "evaluation_threshold_report", + "fake_external_adapter_pack", + "fake_external_runtime_config", + "graph_from_markitect", + "health_report", + "live_shaped_adapter_pack", + "make_review_record", + "merge_path", + "package_request_from_selection", + "package_response_envelope", + "path_event", + "plan_activation", + "plan_compaction", + "plan_lifecycle_from_profile", + "plan_neighborhood_activation", + "plan_phase_transition", + "plan_phase_transitions_from_rules", + "plan_profile_execution", + "plan_refresh", + "plan_retention", + "plan_retention_from_rules", + "profile_from_markitect", + "resolve_runtime_adapters", + "retrieve_graph_neighborhood", + "runtime_from_config", + "select_event_path", + "service_binding_from_config", + "service_contracts", + "validate_adapter_pack_manifest" + ], + "service_operations": [ + "audit.query", + "graph.activation.plan", + "graph.import", + "graph.lifecycle.plan", + "health.check", + "lifecycle.apply", + "package.compile", + "profile.plan" + ] +} diff --git a/tests/test_audit_operations.py b/tests/test_audit_operations.py new file mode 100644 index 0000000..363ca0b --- /dev/null +++ b/tests/test_audit_operations.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from phase_memory.lifecycle import plan_compaction +from phase_memory.models import MemoryNode +from phase_memory.runtime import PhaseMemoryRuntime + + +def test_audit_export_traces_policy_denial_package_compile_and_review_apply() -> None: + runtime = PhaseMemoryRuntime() + graph = { + "schema_version": "markitect.memory.graph.v1", + "id": "audit.graph", + "nodes": [ + { + "id": "node.public", + "kind": "knowledge", + "text": "Public node.", + "policy": {"labels": ["public"], "trust_zone": "local"}, + }, + { + "id": "node.secret", + "kind": "knowledge", + "text": "Secret node.", + "policy": {"labels": ["restricted"], "secret": True, "trust_zone": "local"}, + }, + ], + "edges": [], + "events": [], + } + + activation = runtime.plan_activation( + graph, + max_items=3, + max_tokens=30, + policy_context={"denied_labels": ["restricted"], "secrets_allowed": False, "trust_zone": "local"}, + ) + runtime.compile_package(activation["data"]["package_request"]["selection"]) + node = runtime.graph_store.save_node(MemoryNode("audit.review", "episode", "Review text")) + compact = plan_compaction([node]) + runtime.apply_lifecycle_actions([compact]) + runtime.apply_lifecycle_actions([compact], approval_marker="review:audit") + + export = runtime.export_audit_events() + operations = [event["operation"] for event in export["batch"]["events"]] + + assert export["valid"] is True + assert "graph.activation.plan" in operations + assert "package.compile" in operations + assert operations.count("lifecycle.apply") == 2 + assert activation["data"]["policy_denials"][0]["id"] == "node.secret" + + +def test_audit_retention_plan_identifies_eligible_records() -> None: + runtime = PhaseMemoryRuntime() + runtime.audit_sink.record( + { + "schema_version": "phase_memory.audit.event.v1", + "operation_id": "op:old", + "operation": "manual", + "timestamp": "2026-01-01T00:00:00+00:00", + "subject": {"kind": "audit_events", "id": "old"}, + "source": {"ref": "test"}, + "dry_run": True, + "allowed": True, + } + ) + + plan = runtime.audit_retention_plan(retention_days=30, now=datetime(2026, 5, 19, tzinfo=timezone.utc)) + + assert plan["valid"] is True + assert plan["plan"]["eligible_operation_ids"] == ["op:old"] + assert plan["plan"]["eligible_count"] == 1 diff --git a/tests/test_evaluation_scenarios.py b/tests/test_evaluation_scenarios.py index 13115f8..a60e1f8 100644 --- a/tests/test_evaluation_scenarios.py +++ b/tests/test_evaluation_scenarios.py @@ -4,6 +4,7 @@ from pathlib import Path from phase_memory.adapters import InMemorySemanticIndex from phase_memory.contracts import graph_from_markitect +from phase_memory.evaluation import EVALUATION_REPORT_SCHEMA, evaluation_threshold_report from phase_memory.models import ActivationPlan, MemoryPath from phase_memory.retrieval import activation_quality_report, select_event_path from phase_memory.runtime import PhaseMemoryRuntime @@ -85,6 +86,22 @@ def test_budget_path_and_semantic_hint_scenario_meets_quality_thresholds() -> No assert report["explanation_coverage"] == 1.0 +def test_evaluation_threshold_report_summarizes_all_scenarios() -> None: + data = json.loads((FIXTURES / "evaluation-scenarios.json").read_text(encoding="utf-8")) + + report = evaluation_threshold_report(data) + + assert report["schema_version"] == EVALUATION_REPORT_SCHEMA + assert report["valid"] is True + assert report["metrics"]["scenario_count"] == 3 + assert report["metrics"]["policy_denial_count"] == 1 + assert report["metrics"]["lifecycle_action_count"] >= 3 + assert report["metrics"]["path_event_count"] == 1 + assert report["metrics"]["semantic_hit_count"] == 1 + assert report["metrics"]["budget_omission_count"] == 1 + assert report["diagnostics"] == [] + + def _activation_plan(response): data = response["data"]["activation_plan"] return ActivationPlan( diff --git a/tests/test_external_adapter_packs.py b/tests/test_external_adapter_packs.py index e22c752..ca65155 100644 --- a/tests/test_external_adapter_packs.py +++ b/tests/test_external_adapter_packs.py @@ -7,6 +7,7 @@ from phase_memory.external_adapters import ( adapter_pack_manifest, fake_external_adapter_pack, fake_external_runtime_config, + live_shaped_adapter_pack, validate_adapter_pack_manifest, ) from phase_memory.service import ( @@ -64,6 +65,7 @@ def test_adapter_pack_manifest_reports_missing_capabilities() -> None: capabilities=tuple(capability for capability in pack.capabilities if capability != "telemetry.audit.fake"), ownership_boundaries=pack.ownership_boundaries, required_conformance=pack.required_conformance, + capability_requirements=pack.capability_requirements, metadata=pack.metadata, ) @@ -100,3 +102,32 @@ def test_external_runtime_config_resolves_supplied_fake_pack() -> None: assert fetched["operation"] == "package.compile" assert report["ok"] is True assert report["adapters"]["package_compiler"] == "FakeMarkitectPackageCompiler" + + +def test_live_shaped_adapter_pack_uses_same_manifest_and_conformance_contract() -> None: + config = fake_external_runtime_config() + pack = live_shaped_adapter_pack() + manifest = adapter_pack_manifest(pack) + bundle = resolve_runtime_adapters(config, external_adapters=pack.adapters) + runtime = runtime_from_config(config, external_adapters=pack.adapters) + + assert validate_adapter_pack_manifest(pack) == () + assert manifest["metadata"]["network_required"] is False + assert manifest["adapters"]["package_compiler"]["required_capabilities"] == ["markitect.package.compile.live-shaped"] + assert not [diagnostic for diagnostic in bundle.diagnostics if diagnostic.severity == "error"] + + envelope = runtime.compile_package( + { + "schema_version": "markitect.memory.selection.v1", + "id": "selection.live-shaped", + "nodes": ["decision.boundary"], + "events": [], + } + ) + registry_receipt = bundle.runtime_registry.publish_runtime_envelope(envelope) + fetched = bundle.runtime_registry.fetch_runtime_envelope(registry_receipt["reference"]) + export = runtime.export_audit_events({"operation": "package.compile"}) + + assert envelope["data"]["package_response"]["package_ref"].startswith("markitect-live-shaped:") + assert fetched["operation"] == "package.compile" + assert export["batch"]["retention"]["mode"] == "live_shaped_telemetry" diff --git a/tests/test_file_backed_runtime.py b/tests/test_file_backed_runtime.py index 9a65f0e..1b60812 100644 --- a/tests/test_file_backed_runtime.py +++ b/tests/test_file_backed_runtime.py @@ -1,7 +1,7 @@ import json from pathlib import Path -from phase_memory.adapters import FileBackedMemoryGraphStore, JsonlAuditSink, JsonlMemoryEventLog +from phase_memory.adapters import FileBackedMemoryGraphStore, JsonlAuditSink, JsonlMemoryEventLog, LOCAL_STORE_SCHEMA 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 @@ -112,6 +112,55 @@ def test_file_backed_store_reports_migration_needs_and_uses_atomic_json_writes(t assert not list(tmp_path.rglob("*.tmp")) +def test_local_store_migration_apply_updates_metadata_and_audits(tmp_path) -> None: + store = FileBackedMemoryGraphStore(tmp_path) + runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl")) + (tmp_path / "phase-memory.json").write_text( + json.dumps( + { + "schema_version": "phase_memory.local_store.v0", + "planned_migrations": ["v0-to-v1"], + } + ), + encoding="utf-8", + ) + + planned = runtime.plan_store_migration(source_ref=str(tmp_path)) + applied = runtime.apply_store_migration(planned["data"]["migration_plan"], actor="pytest", source_ref=str(tmp_path)) + metadata = store.metadata() + audit = runtime.query_audit({"operation": "store.migration.apply", "dry_run": False}) + + assert planned["valid"] is True + assert [action["action"] for action in planned["data"]["migration_plan"]["actions"]] == [ + "set_schema_version", + "complete_planned_migration", + ] + assert applied["valid"] is True + assert applied["data"]["migration_result"]["changed"] is True + assert metadata["schema_version"] == LOCAL_STORE_SCHEMA + assert metadata["completed_migrations"] == ["v0-to-v1"] + assert metadata["last_migration"]["actor"] == "pytest" + assert audit["count"] == 1 + + +def test_local_store_migration_noop_and_corrupt_metadata_paths(tmp_path) -> None: + store = FileBackedMemoryGraphStore(tmp_path) + runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl")) + + noop = runtime.apply_store_migration(source_ref=str(tmp_path)) + assert noop["valid"] is True + assert noop["data"]["migration_result"]["changed"] is False + + (tmp_path / "phase-memory.json").write_text("{not-json}\n", encoding="utf-8") + planned = runtime.plan_store_migration(source_ref=str(tmp_path)) + failed = runtime.apply_store_migration(source_ref=str(tmp_path)) + + assert planned["valid"] is False + assert planned["diagnostics"][0]["code"] == "corrupt_store_metadata" + assert failed["valid"] is False + assert failed["data"]["migration_result"]["applied"] is False + + def test_repair_diagnostics_distinguish_corrupt_store_records(tmp_path) -> None: store = FileBackedMemoryGraphStore(tmp_path) runtime = PhaseMemoryRuntime(graph_store=store, event_log=JsonlMemoryEventLog(tmp_path / "events.jsonl")) diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..e7aa945 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,21 @@ +import json +from pathlib import Path + +import phase_memory +from phase_memory.service import LocalServiceRunner, SERVICE_OPERATIONS, service_contracts + + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_public_api_snapshot_is_explicit() -> None: + snapshot = json.loads((FIXTURES / "public-api-snapshot.json").read_text(encoding="utf-8")) + + assert sorted(phase_memory.__all__) == snapshot["exports"] + assert sorted(SERVICE_OPERATIONS) == snapshot["service_operations"] + + +def test_service_contract_catalog_matches_local_runner_supported_operations() -> None: + declared = set(service_contracts()["operations"]) + + assert set(LocalServiceRunner.supported_operations()) == declared diff --git a/tests/test_service_binding.py b/tests/test_service_binding.py new file mode 100644 index 0000000..6782244 --- /dev/null +++ b/tests/test_service_binding.py @@ -0,0 +1,58 @@ +import json +from io import BytesIO + +from phase_memory import LocalServiceRunner, ServiceBinding +from phase_memory.service_binding import READINESS_REPORT_SCHEMA + + +def test_service_binding_exposes_health_readiness_and_contracts_without_listener() -> None: + binding = ServiceBinding(LocalServiceRunner()) + + health = binding.route("GET", "/health") + ready = binding.route("GET", "/ready") + contracts = binding.route("GET", "/contracts") + + assert health.status == 200 + assert ready.status == 200 + assert ready.body["schema_version"] == READINESS_REPORT_SCHEMA + assert ready.body["unsupported_operations"] == [] + assert "package.compile" in contracts.body["operations"] + + +def test_service_binding_dispatches_operation_payloads() -> None: + binding = ServiceBinding(LocalServiceRunner()) + selection = { + "schema_version": "markitect.memory.selection.v1", + "id": "binding.selection", + "nodes": [], + "events": [], + } + + response = binding.route("POST", "/operations/package.compile", {"selection": selection}) + + assert response.status == 200 + assert response.body["operation"] == "package.compile" + assert response.body["data"]["package_response"]["package_ref"] == "package:binding.selection" + + +def test_service_binding_wsgi_adapter_is_callable_without_starting_server() -> None: + binding = ServiceBinding(LocalServiceRunner()) + app = binding.as_wsgi_app() + statuses: list[str] = [] + headers: list[list[tuple[str, str]]] = [] + + payload = json.dumps({"operation": "health.check", "payload": {}}).encode("utf-8") + result = app( + { + "REQUEST_METHOD": "POST", + "PATH_INFO": "/operations", + "CONTENT_LENGTH": str(len(payload)), + "wsgi.input": BytesIO(payload), + }, + lambda status, response_headers: (statuses.append(status), headers.append(response_headers)), + ) + + body = json.loads(b"".join(result).decode("utf-8")) + assert statuses == ["200 OK"] + assert body["schema_version"] == "phase_memory.health.report.v1" + assert ("content-type", "application/json") in headers[0] diff --git a/workplans/PMEM-WP-0012-live-adapter-and-service-binding-readiness.md b/workplans/PMEM-WP-0012-live-adapter-and-service-binding-readiness.md index 1d12a8c..98b46d7 100644 --- a/workplans/PMEM-WP-0012-live-adapter-and-service-binding-readiness.md +++ b/workplans/PMEM-WP-0012-live-adapter-and-service-binding-readiness.md @@ -4,11 +4,11 @@ type: workplan title: "Live Adapter And Service Binding Readiness" domain: markitect repo: phase-memory -status: ready +status: finished owner: codex topic_slug: phase-memory created: "2026-05-18" -updated: "2026-05-18" +updated: "2026-05-19" state_hub_workstream_id: "427b91ad-9df1-4053-aeb0-54ee39b6bf62" --- @@ -38,7 +38,7 @@ evaluation fixtures, adapter pack manifests, and operational recipes. ```task id: PMEM-WP-0012-T01 -status: todo +status: done priority: high state_hub_task_id: "1244aabb-b8a3-4053-8454-499e8772f5bf" ``` @@ -56,7 +56,7 @@ Acceptance: ```task id: PMEM-WP-0012-T02 -status: todo +status: done priority: high state_hub_task_id: "b8d3e7a0-c538-4d6c-b2f8-7c33b17c850a" ``` @@ -73,7 +73,7 @@ Acceptance: ```task id: PMEM-WP-0012-T03 -status: todo +status: done priority: high state_hub_task_id: "e385af31-13f2-4be0-8fcf-89586e2d3954" ``` @@ -91,7 +91,7 @@ Acceptance: ```task id: PMEM-WP-0012-T04 -status: todo +status: done priority: medium state_hub_task_id: "d203294a-bf5a-43d0-a88c-086a3406940d" ``` @@ -108,7 +108,7 @@ Acceptance: ```task id: PMEM-WP-0012-T05 -status: todo +status: done priority: medium state_hub_task_id: "305729e2-23ff-4043-9356-0df83f8e6d7b" ``` @@ -127,7 +127,7 @@ Acceptance: ```task id: PMEM-WP-0012-T06 -status: todo +status: done priority: medium state_hub_task_id: "78f9d0d8-dc9d-4f43-a32d-92e17b3c5122" ``` @@ -149,4 +149,26 @@ Acceptance: ## Closure Review -Pending implementation. +Completed on 2026-05-19. + +Implemented: + +- Optional framework-neutral `ServiceBinding` with health, readiness, + contracts, operation dispatch, and WSGI callable coverage without starting a + listener. +- Executable local-store migration planning and apply behavior with atomic + metadata updates and audited `store.migration.apply` events. +- Live-shaped Markitect, Kontextual, policy, telemetry, semantic-index, and + runtime-registry adapter fixtures behind the same manifest and conformance + suite used by fake packs. +- Audit export batches and retention plans for recording, JSONL, and telemetry + audit sinks. +- Evaluation threshold reporting over the policy/lifecycle/path/semantic/budget + scenario corpus. +- Public API and service operation compatibility snapshots with documentation. +- Scorecard update from 4.0 to 4.2 and PMEM-WP-0013 as the next ready + refinement workplan. + +Verification: + +- Focused PMEM-WP-0012 tests passed: 38 tests. diff --git a/workplans/PMEM-WP-0013-credentialed-adapter-drills-and-deployment-packaging.md b/workplans/PMEM-WP-0013-credentialed-adapter-drills-and-deployment-packaging.md new file mode 100644 index 0000000..9bea3d3 --- /dev/null +++ b/workplans/PMEM-WP-0013-credentialed-adapter-drills-and-deployment-packaging.md @@ -0,0 +1,150 @@ +--- +id: PMEM-WP-0013 +type: workplan +title: "Credentialed Adapter Drills And Deployment Packaging" +domain: markitect +repo: phase-memory +status: ready +owner: codex +topic_slug: phase-memory +created: "2026-05-19" +updated: "2026-05-19" +state_hub_workstream_id: "3343c4bf-40f4-42c9-9713-2a441349f723" +--- + +# PMEM-WP-0013: Credentialed Adapter Drills And Deployment Packaging + +## Goal + +Move from live-shaped local fixtures to optional credentialed operational +drills, deployable service packaging, and compatibility release discipline. + +## Current Evidence + +`PMEM-WP-0012` added service binding, executable local-store migrations, +live-shaped adapter packs, audit retention/export plans, evaluation threshold +reports, and public API snapshots. The scorecard now rates the repo at +**4.2 / 5**. + +## Non-Goals + +- Require live credentials in default tests. +- Store credentials, tokens, or endpoints in Git. +- Make one hosted service topology mandatory. +- Move Markitect or Kontextual ownership into phase-memory. + +## T01 - Add credential-gated adapter smoke drills + +```task +id: PMEM-WP-0013-T01 +status: todo +priority: high +state_hub_task_id: "e4940a9d-130e-47ea-ba16-7b090841855c" +``` + +Add optional smoke tests for credentialed Markitect/Kontextual adapters that +skip unless explicit environment variables are present. + +Acceptance: + +- Default test suite remains offline and deterministic. +- Credentialed drills reuse adapter manifest validation and conformance helpers. +- Skipped tests clearly list the required environment variables. + +## T02 - Package the service binding for deployment + +```task +id: PMEM-WP-0013-T02 +status: todo +priority: high +state_hub_task_id: "bf8d2159-761a-47f5-b7be-41ad52460b64" +``` + +Add a deployable service entrypoint around the framework-neutral binding. + +Acceptance: + +- The package can expose health, readiness, contracts, and operation dispatch + over a local service process. +- Deployment remains optional and dependency-light. +- Tests cover entrypoint construction without opening a real listener. + +## T03 - Add operator readiness runbook + +```task +id: PMEM-WP-0013-T03 +status: todo +priority: medium +state_hub_task_id: "7e39e894-8754-4977-abdd-00f3bf1a73d1" +``` + +Document service startup, readiness checks, migration apply, audit export, and +rollback expectations for operators. + +Acceptance: + +- Runbook covers happy path and failure triage. +- It distinguishes local fixture mode, live-shaped mode, and credentialed live + mode. +- It links back to API compatibility and maturity gates. + +## T04 - Enforce audit retention pruning plans + +```task +id: PMEM-WP-0013-T04 +status: todo +priority: medium +state_hub_task_id: "b23e3126-bbfa-44b1-b2a1-22cda968f5d8" +``` + +Move from retention planning to explicit retention apply behavior. + +Acceptance: + +- Audit sinks can produce and apply deterministic pruning plans. +- Apply behavior records an audit event. +- Tests cover no-op, eligible-record, and unsupported-sink paths. + +## T05 - Add evaluation trend artifacts + +```task +id: PMEM-WP-0013-T05 +status: todo +priority: medium +state_hub_task_id: "2e71fedd-aac6-42c9-822c-6305412ea064" +``` + +Persist deterministic evaluation reports as trendable artifacts. + +Acceptance: + +- Reports include run metadata and threshold deltas. +- Fixtures can be compared without live dependencies. +- Regression diagnostics remain actionable. + +## T06 - Add compatibility release notes template + +```task +id: PMEM-WP-0013-T06 +status: todo +priority: low +state_hub_task_id: "c1a8f699-9a0b-4983-8d35-e59cd124dd58" +``` + +Add release-note and migration-note templates for public API changes. + +Acceptance: + +- Template calls out changed exports, changed service operations, migration + needs, and operator action. +- API snapshot updates require a release note reference. + +## Acceptance Criteria + +- The project has concrete evidence toward the 4.3+ scorecard gate. +- Credentialed adapter drills are available but never required by default. +- Service binding can be packaged and exercised as an operator-facing service. + +## Closure Review + +Pending implementation.