From 8c82ca754cf48adc7962442eb41e5590e1cefb80 Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 16:25:21 +0800 Subject: [PATCH 1/4] Extract runtime facade compatibility layer onto main Re-cut the runtime facade compatibility work from the old stacked PR onto the current main branch after PR 211 merged. Key changes: - restore public facade exports for checkpoint, embedding, event, hook, plan, security, and transport through defaults/adapters modules instead of direct _internal imports - preserve permissive security boundary compatibility and deep-copy checkpoint payload snapshots for stable round-trips - bring over the facade-focused regression coverage and min-surface review note - intentionally exclude the old stacked PR's compression/context baseline implementation changes Rationale: The previous PR 212 was stacked on an outdated pre-merge PR 211 base and still carried obsolete compression/context commits. This commit rebuilds the intended facade/checkpoint scope directly on top of main so the PR can be reviewed and merged independently. --- dare_framework/checkpoint/defaults.py | 225 ++++++++++++++++++ dare_framework/embedding/__init__.py | 6 +- dare_framework/embedding/defaults.py | 5 + dare_framework/event/__init__.py | 2 +- dare_framework/event/defaults.py | 5 + dare_framework/hook/__init__.py | 2 +- dare_framework/hook/defaults.py | 5 + dare_framework/plan/__init__.py | 3 +- dare_framework/plan/defaults.py | 6 + dare_framework/security/__init__.py | 7 +- .../impl/default_security_boundary.py | 12 +- dare_framework/transport/__init__.py | 14 +- dare_framework/transport/adapters.py | 15 ++ docs/design/Framework_MinSurface_Review.md | 5 + tests/unit/test_checkpoint_defaults.py | 198 +++++++++++++++ ...est_package_initializers_facade_pattern.py | 52 ++++ tests/unit/test_security_boundary.py | 52 +++- 17 files changed, 595 insertions(+), 19 deletions(-) create mode 100644 dare_framework/checkpoint/defaults.py create mode 100644 dare_framework/embedding/defaults.py create mode 100644 dare_framework/event/defaults.py create mode 100644 dare_framework/hook/defaults.py create mode 100644 dare_framework/plan/defaults.py create mode 100644 dare_framework/transport/adapters.py create mode 100644 tests/unit/test_checkpoint_defaults.py diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py new file mode 100644 index 00000000..8abfdf5f --- /dev/null +++ b/dare_framework/checkpoint/defaults.py @@ -0,0 +1,225 @@ +"""Supported default checkpoint implementations and contributors. + +This module keeps legacy checkpoint symbols importable after checkpoint internals +were consolidated into ``dare_framework.checkpoint.kernel``. +""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import asdict +from typing import Any +from uuid import uuid4 + +from dare_framework.context.types import Message + +STM = "stm" +SESSION_STATE = "session_state" +SESSION_CONTEXT = "session_context" +WORKSPACE_FILES = "workspace_files" + + +class MemoryCheckpointStore: + """In-memory checkpoint payload store kept for facade compatibility.""" + + def __init__(self) -> None: + self._store: dict[str, dict[str, Any]] = {} + + def put(self, checkpoint_id: str, payload: dict[str, Any]) -> None: + self._store[checkpoint_id] = _clone_payload(payload) + + def get(self, checkpoint_id: str) -> dict[str, Any] | None: + if checkpoint_id not in self._store: + return None + return _clone_payload(self._store[checkpoint_id]) + + def delete(self, checkpoint_id: str) -> bool: + if checkpoint_id in self._store: + del self._store[checkpoint_id] + return True + return False + + +def _scope_keys(scope: Any, method_name: str) -> list[str]: + method = getattr(scope, method_name, None) + if callable(method): + values = method() + if isinstance(values, (list, tuple, set)): + return [str(v) for v in values] + if isinstance(scope, (list, tuple, set)): + return [str(v) for v in scope] + return [] + + +def _clone_payload(value: Any) -> Any: + """Clone nested checkpoint payloads so save/restore stays side-effect free.""" + return deepcopy(value) + + +class DefaultCheckpointSaveRestore: + """Legacy save/restore coordinator over contributor payload components.""" + + def __init__(self, store: MemoryCheckpointStore, contributors: list[Any]) -> None: + self._store = store + self._contributors = { + str(c.component_key): c + for c in contributors + if getattr(c, "component_key", None) is not None + } + + def save(self, scope: Any, ctx: Any) -> str: + payload: dict[str, Any] = {} + for key in _scope_keys(scope, "keys_for_save"): + contributor = self._contributors.get(key) + if contributor is None: + continue + payload[key] = contributor.serialize(ctx) + checkpoint_id = uuid4().hex[:16] + self._store.put(checkpoint_id, payload) + return checkpoint_id + + def restore(self, checkpoint_id: str, scope: Any, ctx: Any) -> None: + payload = self._store.get(checkpoint_id) + if payload is None: + raise LookupError(f"Checkpoint not found: {checkpoint_id!r}") + for key in _scope_keys(scope, "keys_for_restore"): + if key not in payload: + continue + contributor = self._contributors.get(key) + if contributor is None: + continue + contributor.deserialize_and_apply(payload[key], ctx) + + +class StmContributor: + """Serialize/restore short-term memory messages.""" + + component_key = STM + + def serialize(self, ctx: Any) -> list[dict[str, Any]]: + context = getattr(ctx, "context", None) + if context is None: + return [] + messages = context.stm_get() + return [ + { + "role": m.role, + "kind": m.kind, + "text": m.text, + "attachments": [ + { + "kind": attachment.kind, + "uri": attachment.uri, + "mime_type": attachment.mime_type, + "filename": attachment.filename, + "metadata": _clone_payload(getattr(attachment, "metadata", {}) or {}), + } + for attachment in m.attachments + ], + "data": _clone_payload(m.data), + "name": m.name, + "metadata": _clone_payload(getattr(m, "metadata", {}) or {}), + "mark": getattr(m, "mark", None), + "id": getattr(m, "id", None), + } + for m in messages + ] + + def deserialize_and_apply(self, payload: list[Any], ctx: Any) -> None: + context = getattr(ctx, "context", None) + if context is None: + return + context.stm_clear() + for item in payload or []: + if not isinstance(item, dict): + continue + context.stm_add( + Message( + role=item.get("role", "user"), + kind=item.get("kind", "chat"), + text=item.get("text") or item.get("content", ""), + attachments=_clone_payload(item.get("attachments")) or [], + data=_clone_payload(item.get("data")), + name=item.get("name"), + metadata=dict(item.get("metadata") or {}), + mark=item.get("mark", "temporary"), + id=item.get("id"), + ) + ) + + +class SessionStateContributor: + """Serialize/restore minimal session-state fields.""" + + component_key = SESSION_STATE + + def serialize(self, ctx: Any) -> dict[str, Any] | None: + state = getattr(ctx, "session_state", None) + if state is None: + return None + try: + return asdict(state) + except Exception: + return { + "current_milestone_idx": getattr(state, "current_milestone_idx", None), + "task_id": getattr(state, "task_id", None), + "run_id": getattr(state, "run_id", None), + } + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + state = getattr(ctx, "session_state", None) + if state is None or not isinstance(payload, dict): + return + if "current_milestone_idx" in payload and hasattr(state, "current_milestone_idx"): + state.current_milestone_idx = payload["current_milestone_idx"] + + +class SessionContextContributor: + """Serialize session context for audit trails (restore intentionally no-op).""" + + component_key = SESSION_CONTEXT + + def serialize(self, ctx: Any) -> dict[str, Any] | None: + session_context = getattr(ctx, "session_context", None) + if session_context is None: + return None + try: + serialized = asdict(session_context) + except Exception: + return { + "session_id": getattr(session_context, "session_id", None), + "task_id": getattr(session_context, "task_id", None), + } + config_value = serialized.get("config") + if config_value is not None and not isinstance(config_value, dict): + try: + serialized["config"] = asdict(config_value) + except Exception: + serialized["config"] = None + return serialized + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + _ = (payload, ctx) + # SessionContext is construction-time state; legacy restore path keeps this as no-op. + + +class WorkspaceGitContributor: + """Compatibility no-op contributor after workspace-git checkpoint removal.""" + + component_key = WORKSPACE_FILES + + def serialize(self, ctx: Any) -> dict[str, Any]: + _ = ctx + return {} + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + _ = (payload, ctx) + +__all__ = [ + "MemoryCheckpointStore", + "DefaultCheckpointSaveRestore", + "StmContributor", + "WorkspaceGitContributor", + "SessionStateContributor", + "SessionContextContributor", +] diff --git a/dare_framework/embedding/__init__.py b/dare_framework/embedding/__init__.py index 8d1de770..60d4b447 100644 --- a/dare_framework/embedding/__init__.py +++ b/dare_framework/embedding/__init__.py @@ -1,8 +1,8 @@ """embedding domain facade.""" -from dare_framework.embedding.interfaces import IEmbeddingAdapter -from dare_framework.embedding.types import EmbeddingOptions, EmbeddingResult -from dare_framework.embedding._internal.openai_embedding import OpenAIEmbeddingAdapter +from dare_framework.embedding.interfaces import IEmbeddingAdapter +from dare_framework.embedding.types import EmbeddingOptions, EmbeddingResult +from dare_framework.embedding.defaults import OpenAIEmbeddingAdapter __all__ = [ "IEmbeddingAdapter", diff --git a/dare_framework/embedding/defaults.py b/dare_framework/embedding/defaults.py new file mode 100644 index 00000000..88e38524 --- /dev/null +++ b/dare_framework/embedding/defaults.py @@ -0,0 +1,5 @@ +"""Supported default embedding implementations.""" + +from dare_framework.embedding._internal.openai_embedding import OpenAIEmbeddingAdapter + +__all__ = ["OpenAIEmbeddingAdapter"] diff --git a/dare_framework/event/__init__.py b/dare_framework/event/__init__.py index a7ac3831..703a2740 100644 --- a/dare_framework/event/__init__.py +++ b/dare_framework/event/__init__.py @@ -1,6 +1,6 @@ """event domain facade.""" -from dare_framework.event._internal.sqlite_event_log import DefaultEventLog, SQLiteEventLog +from dare_framework.event.defaults import DefaultEventLog, SQLiteEventLog from dare_framework.event.kernel import IEventLog from dare_framework.event.types import Event, RuntimeSnapshot diff --git a/dare_framework/event/defaults.py b/dare_framework/event/defaults.py new file mode 100644 index 00000000..d5eee182 --- /dev/null +++ b/dare_framework/event/defaults.py @@ -0,0 +1,5 @@ +"""Supported default event-log implementations.""" + +from dare_framework.event._internal.sqlite_event_log import DefaultEventLog, SQLiteEventLog + +__all__ = ["SQLiteEventLog", "DefaultEventLog"] diff --git a/dare_framework/hook/__init__.py b/dare_framework/hook/__init__.py index 3621b4f1..1d1d3c25 100644 --- a/dare_framework/hook/__init__.py +++ b/dare_framework/hook/__init__.py @@ -2,7 +2,7 @@ from dare_framework.hook.interfaces import IHookManager from dare_framework.hook.kernel import IExtensionPoint, IHook, HookFn -from dare_framework.hook._internal.hook_extension_point import HookExtensionPoint +from dare_framework.hook.defaults import HookExtensionPoint from dare_framework.hook.types import HookDecision, HookEnvelope, HookPhase, HookResult __all__ = [ diff --git a/dare_framework/hook/defaults.py b/dare_framework/hook/defaults.py new file mode 100644 index 00000000..2037a192 --- /dev/null +++ b/dare_framework/hook/defaults.py @@ -0,0 +1,5 @@ +"""Supported default extension-point implementation.""" + +from dare_framework.hook._internal.hook_extension_point import HookExtensionPoint + +__all__ = ["HookExtensionPoint"] diff --git a/dare_framework/plan/__init__.py b/dare_framework/plan/__init__.py index 9daa9392..59716d99 100644 --- a/dare_framework/plan/__init__.py +++ b/dare_framework/plan/__init__.py @@ -23,8 +23,7 @@ ValidatedStep, VerifyResult, ) -from dare_framework.plan._internal.default_planner import DefaultPlanner -from dare_framework.plan._internal.default_remediator import DefaultRemediator +from dare_framework.plan.defaults import DefaultPlanner, DefaultRemediator __all__ = [ # Interfaces diff --git a/dare_framework/plan/defaults.py b/dare_framework/plan/defaults.py new file mode 100644 index 00000000..6f0edc33 --- /dev/null +++ b/dare_framework/plan/defaults.py @@ -0,0 +1,6 @@ +"""Supported default planner/remediator implementations.""" + +from dare_framework.plan._internal.default_planner import DefaultPlanner +from dare_framework.plan._internal.default_remediator import DefaultRemediator + +__all__ = ["DefaultPlanner", "DefaultRemediator"] diff --git a/dare_framework/security/__init__.py b/dare_framework/security/__init__.py index add8eb8a..ade0d6f6 100644 --- a/dare_framework/security/__init__.py +++ b/dare_framework/security/__init__.py @@ -1,6 +1,5 @@ """Security domain facade.""" -from dare_framework.security._internal.default_security_boundary import DefaultSecurityBoundary from dare_framework.security.errors import ( SECURITY_APPROVAL_MANAGER_MISSING, SECURITY_POLICY_CHECK_FAILED, @@ -8,7 +7,11 @@ SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError, ) -from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary +from dare_framework.security.impl import ( + DefaultSecurityBoundary, + NoOpSecurityBoundary, + PolicySecurityBoundary, +) from dare_framework.security.kernel import ISecurityBoundary from dare_framework.security.types import PolicyDecision, RiskLevel, SandboxSpec, TrustedInput diff --git a/dare_framework/security/impl/default_security_boundary.py b/dare_framework/security/impl/default_security_boundary.py index 46b17f4b..e2c0f456 100644 --- a/dare_framework/security/impl/default_security_boundary.py +++ b/dare_framework/security/impl/default_security_boundary.py @@ -5,6 +5,9 @@ import inspect from typing import Any, Callable, Iterable +from dare_framework.security._internal.default_security_boundary import ( + DefaultSecurityBoundary as LegacyDefaultSecurityBoundary, +) from dare_framework.security.errors import ( SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError, @@ -259,8 +262,13 @@ async def execute_safe( return await _execute_callable(fn) -# Backward-compatible alias for legacy references. -DefaultSecurityBoundary = PolicySecurityBoundary +class DefaultSecurityBoundary(LegacyDefaultSecurityBoundary): + """Permissive default boundary with legacy `from_config()` compatibility.""" + + @classmethod + def from_config(cls, config: dict[str, Any] | None) -> PolicySecurityBoundary: + """Preserve the historical config-driven constructor contract.""" + return PolicySecurityBoundary.from_config(config) __all__ = [ diff --git a/dare_framework/transport/__init__.py b/dare_framework/transport/__init__.py index 8be5b978..9057aa9e 100644 --- a/dare_framework/transport/__init__.py +++ b/dare_framework/transport/__init__.py @@ -4,27 +4,27 @@ from dare_framework.transport.serialization import jsonify_transport_value from dare_framework.transport.types import ( ActionPayload, + AttachmentRef, ControlPayload, - EnvelopePayload, EnvelopeKind, - MessageRole, + EnvelopePayload, MessageKind, MessagePayload, - AttachmentRef, + MessageRole, + Receiver, SelectDomain, SelectKind, SelectPayload, + Sender, TransportEnvelope, new_envelope_id, - Receiver, - Sender, ) from dare_framework.transport.interaction import ( - AgentControl, ActionHandlerDispatcher, + AgentControl, ResourceAction, ) -from dare_framework.transport._internal import ( +from dare_framework.transport.adapters import ( DefaultAgentChannel, DirectClientChannel, StdioClientChannel, diff --git a/dare_framework/transport/adapters.py b/dare_framework/transport/adapters.py new file mode 100644 index 00000000..9dadb2c9 --- /dev/null +++ b/dare_framework/transport/adapters.py @@ -0,0 +1,15 @@ +"""Supported default transport channel adapters.""" + +from dare_framework.transport._internal import ( + DefaultAgentChannel, + DirectClientChannel, + StdioClientChannel, + WebSocketClientChannel, +) + +__all__ = [ + "DefaultAgentChannel", + "DirectClientChannel", + "StdioClientChannel", + "WebSocketClientChannel", +] diff --git a/docs/design/Framework_MinSurface_Review.md b/docs/design/Framework_MinSurface_Review.md index 4d80b193..bc8ec18a 100644 --- a/docs/design/Framework_MinSurface_Review.md +++ b/docs/design/Framework_MinSurface_Review.md @@ -268,3 +268,8 @@ MCP adapters, etc. - Tight coupling to concrete classes prevents safe refactors. - External users rely on internal DTOs that must then be supported forever. - Increased likelihood of breaking changes with each internal improvement. + +## 5. Implementation Note (2026-03-04) +- T0-4 facade compliance batch moved public-domain `__init__.py` imports away from direct `._internal` references into explicit public export layers (`defaults.py`, `impl`, `adapters`). +- The following facades now import only public modules directly: `checkpoint`, `embedding`, `event`, `hook`, `plan`, `security`, `transport`. +- A regression rule was added in `tests/unit/test_package_initializers_facade_pattern.py` to block future direct `._internal` imports from public facades. diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py new file mode 100644 index 00000000..cb6854e9 --- /dev/null +++ b/tests/unit/test_checkpoint_defaults.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import importlib +from dataclasses import dataclass + +from dare_framework.config import Config +from dare_framework.context import AttachmentKind, AttachmentRef, Context, Message, MessageKind, MessageMark + + +def test_checkpoint_defaults_module_exports_are_importable() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + + for symbol in ( + "MemoryCheckpointStore", + "DefaultCheckpointSaveRestore", + "StmContributor", + "WorkspaceGitContributor", + "SessionStateContributor", + "SessionContextContributor", + ): + assert hasattr(defaults, symbol), f"missing checkpoint default symbol: {symbol}" + + +@dataclass +class _DummyConfig: + model: str + + +@dataclass +class _DummySessionContext: + session_id: str + task_id: str + config: _DummyConfig | None + + +@dataclass +class _DummyCtx: + session_context: _DummySessionContext | None + + +def test_session_context_contributor_preserves_serialized_config_dict() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.SessionContextContributor() + ctx = _DummyCtx( + session_context=_DummySessionContext( + session_id="s-1", + task_id="t-1", + config=_DummyConfig(model="gpt-4.1"), + ) + ) + + payload = contributor.serialize(ctx) + + assert payload is not None + assert payload["config"] == {"model": "gpt-4.1"} + + +def test_stm_contributor_roundtrip_uses_current_message_text_shape() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.StmContributor() + + source_context = Context(config=Config()) + source_context.stm_add( + Message(role="user", text="hello", name="alice", metadata={"trace_id": "t-1"}) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + + payload = contributor.serialize(source_ctx) + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + contributor.deserialize_and_apply(payload, restored_ctx) + + restored_messages = restored_context.stm_get() + assert len(restored_messages) == 1 + assert restored_messages[0].text == "hello" + assert restored_messages[0].name == "alice" + assert restored_messages[0].metadata == {"trace_id": "t-1"} + + +def test_stm_contributor_roundtrip_preserves_typed_message_payloads() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.StmContributor() + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="user", + text="see attachment", + attachments=[ + AttachmentRef( + kind=AttachmentKind.IMAGE, + uri="https://example.com/a.png", + metadata={"source": "camera"}, + ) + ], + mark=MessageMark.PERSISTENT, + id="user-1", + ) + ) + source_context.stm_add( + Message( + role="assistant", + kind=MessageKind.TOOL_CALL, + text="", + data={"tool_calls": [{"id": "tc_1", "name": "demo_tool", "arguments": {"x": 1}}]}, + mark=MessageMark.IMMUTABLE, + id="assistant-1", + ) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + + payload = contributor.serialize(source_ctx) + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + contributor.deserialize_and_apply(payload, restored_ctx) + + restored_messages = restored_context.stm_get() + assert len(restored_messages) == 2 + + assert restored_messages[0].attachments[0].uri == "https://example.com/a.png" + assert restored_messages[0].attachments[0].metadata == {"source": "camera"} + assert restored_messages[0].mark == MessageMark.PERSISTENT + assert restored_messages[0].id == "user-1" + + assert restored_messages[1].kind == MessageKind.TOOL_CALL + assert restored_messages[1].data == { + "tool_calls": [{"id": "tc_1", "name": "demo_tool", "arguments": {"x": 1}}] + } + assert restored_messages[1].mark == MessageMark.IMMUTABLE + assert restored_messages[1].id == "assistant-1" + + +def test_checkpoint_save_snapshot_deep_copies_nested_message_metadata() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + store = defaults.MemoryCheckpointStore() + saver = defaults.DefaultCheckpointSaveRestore(store, [defaults.StmContributor()]) + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="user", + text="hello", + metadata={"trace": {"step": 1}}, + ) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + scope = type( + "Scope", + (), + {"keys_for_save": lambda self: ["stm"], "keys_for_restore": lambda self: ["stm"]}, + )() + + checkpoint_id = saver.save(scope, source_ctx) + + source_context.stm_get()[0].metadata["trace"]["step"] = 99 + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + saver.restore(checkpoint_id, scope, restored_ctx) + + restored_messages = restored_context.stm_get() + assert restored_messages[0].metadata == {"trace": {"step": 1}} + + +def test_checkpoint_store_get_returns_deep_copied_payloads() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + store = defaults.MemoryCheckpointStore() + saver = defaults.DefaultCheckpointSaveRestore(store, [defaults.StmContributor()]) + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="user", + text="hello", + metadata={"trace": {"step": 1}}, + ) + ) + scope = type( + "Scope", + (), + {"keys_for_save": lambda self: ["stm"], "keys_for_restore": lambda self: ["stm"]}, + )() + source_ctx = type("Ctx", (), {"context": source_context})() + + checkpoint_id = saver.save(scope, source_ctx) + + restored_context_a = Context(config=Config()) + restored_ctx_a = type("Ctx", (), {"context": restored_context_a})() + saver.restore(checkpoint_id, scope, restored_ctx_a) + restored_context_a.stm_get()[0].metadata["trace"]["step"] = 77 + + restored_context_b = Context(config=Config()) + restored_ctx_b = type("Ctx", (), {"context": restored_context_b})() + saver.restore(checkpoint_id, scope, restored_ctx_b) + + assert restored_context_b.stm_get()[0].metadata == {"trace": {"step": 1}} diff --git a/tests/unit/test_package_initializers_facade_pattern.py b/tests/unit/test_package_initializers_facade_pattern.py index 3990969b..1bc66235 100644 --- a/tests/unit/test_package_initializers_facade_pattern.py +++ b/tests/unit/test_package_initializers_facade_pattern.py @@ -4,6 +4,42 @@ from pathlib import Path +def _is_internal_module_path(module: str) -> bool: + return module == "_internal" or module.startswith("_internal.") or "._internal" in module + + +def _find_direct_internal_import(node: ast.stmt) -> str | None: + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module and _is_internal_module_path(module): + return f"{'.' * node.level}{module}" + + # `from . import _internal` uses `module is None` and stores names in aliases. + for alias in node.names: + if _is_internal_module_path(alias.name): + return f"{'.' * node.level}{alias.name}" + return None + + if isinstance(node, ast.Import): + for alias in node.names: + if _is_internal_module_path(alias.name): + return alias.name + return None + + +def test_internal_import_detection_covers_relative_syntax() -> None: + cases = { + "from ._internal.foo import Bar": "._internal.foo", + "from .._internal import Foo": ".._internal", + "from . import _internal": "._internal", + "from dare_framework.tool._internal.tools import EchoTool": "dare_framework.tool._internal.tools", + "from dare_framework.tool.defaults import EchoTool": None, + } + for source, expected in cases.items(): + node = ast.parse(source).body[0] + assert _find_direct_internal_import(node) == expected + + def test_package_initializers_follow_facade_pattern() -> None: """Asserts that all dare_framework initializers use the facade pattern. @@ -12,6 +48,8 @@ def test_package_initializers_follow_facade_pattern() -> None: 2. No class or function definitions. 3. Assignments allowed only for metadata (e.g., __all__, __version__). 4. Imports allowed for re-exporting. + 5. Star imports are prohibited. + 6. Public facades must not import from `._internal` directly. """ repo_root = Path(__file__).resolve().parents[2] package_root = repo_root / "dare_framework" @@ -21,6 +59,7 @@ def test_package_initializers_follow_facade_pattern() -> None: violations: list[str] = [] for path in init_files: source = path.read_text(encoding="utf-8") + is_public_facade = "_internal" not in path.parts if not source.strip(): # Empty files are okay if they are just placeholders, # but the user wanted docstrings. @@ -68,6 +107,19 @@ def test_package_initializers_follow_facade_pattern() -> None: violations.append(f"{path.relative_to(repo_root)}: Prohibited assignment (only __all__/__version__ etc allowed)") # Rule 4: Imports are allowed (for re-exporting) + if isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names): + violations.append(f"{path.relative_to(repo_root)}: Prohibited star import in package initializer") + continue + + # Rule 6: Public facades must not directly import internal modules. + if is_public_facade: + direct_internal_import = _find_direct_internal_import(node) + if direct_internal_import is not None: + violations.append( + f"{path.relative_to(repo_root)}: Prohibited direct _internal import ({direct_internal_import})" + ) + continue + if isinstance(node, (ast.Import, ast.ImportFrom)): continue diff --git a/tests/unit/test_security_boundary.py b/tests/unit/test_security_boundary.py index 029c7a19..b95859e3 100644 --- a/tests/unit/test_security_boundary.py +++ b/tests/unit/test_security_boundary.py @@ -2,8 +2,13 @@ import pytest +from dare_framework.security import DefaultSecurityBoundary from dare_framework.security.errors import SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError -from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary +from dare_framework.security.impl import ( + DefaultSecurityBoundary as ImplDefaultSecurityBoundary, + NoOpSecurityBoundary, + PolicySecurityBoundary, +) from dare_framework.security.types import PolicyDecision, RiskLevel @@ -44,6 +49,51 @@ async def test_policy_security_boundary_requires_approval_for_high_risk() -> Non assert decision is PolicyDecision.APPROVE_REQUIRED +@pytest.mark.asyncio +async def test_default_security_boundary_remains_permissive_for_high_risk() -> None: + boundary = DefaultSecurityBoundary() + trusted = await boundary.verify_trust( + input={"command": "rm -rf /tmp/foo"}, + context={ + "capability_id": "run_command", + "risk_level": RiskLevel.NON_IDEMPOTENT_EFFECT.value, + "requires_approval": True, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="run_command", + context={"trusted_input": trusted, "capability_id": "run_command", "requires_approval": True}, + ) + + assert decision is PolicyDecision.ALLOW + + +@pytest.mark.asyncio +async def test_impl_default_security_boundary_from_config_preserves_policy_constructor() -> None: + boundary = ImplDefaultSecurityBoundary.from_config( + { + "approval_required_risk_levels": [RiskLevel.READ_ONLY.value], + "default_decision": PolicyDecision.DENY.value, + } + ) + trusted = await boundary.verify_trust( + input={"path": "README.md"}, + context={ + "capability_id": "read_file", + "risk_level": RiskLevel.READ_ONLY.value, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="read_file", + context={"trusted_input": trusted, "capability_id": "read_file"}, + ) + + assert isinstance(boundary, PolicySecurityBoundary) + assert decision is PolicyDecision.APPROVE_REQUIRED + + @pytest.mark.asyncio async def test_policy_security_boundary_denies_blocked_capability() -> None: boundary = PolicySecurityBoundary(deny_capability_ids={"run_command"}) From 2c0cbe6a94ca3e00bcbf20f77fd277ef99aae2a5 Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 16:53:24 +0800 Subject: [PATCH 2/4] Preserve security impl default boundary semantics Address the PR 213 review finding around semantics. Key changes: - restore as the policy-boundary alias so existing impl imports keep constructor kwargs and approval/deny behavior - add to expose the permissive public baseline boundary without routing the package initializer through - update the public security facade to import from the new defaults module - add regression coverage proving the impl constructor path still behaves like Rationale: The previous refactor conflated the public permissive facade with the historical alias and changed behavior for deployments that import from . This split preserves both compatibility layers with their original semantics. --- dare_framework/security/__init__.py | 2 +- dare_framework/security/defaults.py | 5 ++++ .../impl/default_security_boundary.py | 12 ++-------- tests/unit/test_security_boundary.py | 23 +++++++++++++++++++ 4 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 dare_framework/security/defaults.py diff --git a/dare_framework/security/__init__.py b/dare_framework/security/__init__.py index ade0d6f6..4a2e9b3c 100644 --- a/dare_framework/security/__init__.py +++ b/dare_framework/security/__init__.py @@ -1,5 +1,6 @@ """Security domain facade.""" +from dare_framework.security.defaults import DefaultSecurityBoundary from dare_framework.security.errors import ( SECURITY_APPROVAL_MANAGER_MISSING, SECURITY_POLICY_CHECK_FAILED, @@ -8,7 +9,6 @@ SecurityBoundaryError, ) from dare_framework.security.impl import ( - DefaultSecurityBoundary, NoOpSecurityBoundary, PolicySecurityBoundary, ) diff --git a/dare_framework/security/defaults.py b/dare_framework/security/defaults.py new file mode 100644 index 00000000..bd6a0611 --- /dev/null +++ b/dare_framework/security/defaults.py @@ -0,0 +1,5 @@ +"""Public default security boundary exports.""" + +from dare_framework.security._internal.default_security_boundary import DefaultSecurityBoundary + +__all__ = ["DefaultSecurityBoundary"] diff --git a/dare_framework/security/impl/default_security_boundary.py b/dare_framework/security/impl/default_security_boundary.py index e2c0f456..6cea686f 100644 --- a/dare_framework/security/impl/default_security_boundary.py +++ b/dare_framework/security/impl/default_security_boundary.py @@ -5,9 +5,6 @@ import inspect from typing import Any, Callable, Iterable -from dare_framework.security._internal.default_security_boundary import ( - DefaultSecurityBoundary as LegacyDefaultSecurityBoundary, -) from dare_framework.security.errors import ( SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError, @@ -262,13 +259,8 @@ async def execute_safe( return await _execute_callable(fn) -class DefaultSecurityBoundary(LegacyDefaultSecurityBoundary): - """Permissive default boundary with legacy `from_config()` compatibility.""" - - @classmethod - def from_config(cls, config: dict[str, Any] | None) -> PolicySecurityBoundary: - """Preserve the historical config-driven constructor contract.""" - return PolicySecurityBoundary.from_config(config) +# Backward-compatible alias for legacy impl references. +DefaultSecurityBoundary = PolicySecurityBoundary __all__ = [ diff --git a/tests/unit/test_security_boundary.py b/tests/unit/test_security_boundary.py index b95859e3..59e08603 100644 --- a/tests/unit/test_security_boundary.py +++ b/tests/unit/test_security_boundary.py @@ -94,6 +94,29 @@ async def test_impl_default_security_boundary_from_config_preserves_policy_const assert decision is PolicyDecision.APPROVE_REQUIRED +@pytest.mark.asyncio +async def test_impl_default_security_boundary_constructor_preserves_policy_semantics() -> None: + boundary = ImplDefaultSecurityBoundary( + approval_required_risk_levels={RiskLevel.READ_ONLY}, + default_decision=PolicyDecision.DENY, + ) + trusted = await boundary.verify_trust( + input={"path": "README.md"}, + context={ + "capability_id": "read_file", + "risk_level": RiskLevel.READ_ONLY.value, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="read_file", + context={"trusted_input": trusted, "capability_id": "read_file"}, + ) + + assert isinstance(boundary, PolicySecurityBoundary) + assert decision is PolicyDecision.APPROVE_REQUIRED + + @pytest.mark.asyncio async def test_policy_security_boundary_denies_blocked_capability() -> None: boundary = PolicySecurityBoundary(deny_capability_ids={"run_command"}) From 51554c8e58f87db7a44621306e11f9f1dcd17158 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 11 Mar 2026 17:29:44 +0800 Subject: [PATCH 3/4] fix(checkpoint): restore session identifiers on checkpoint load Restore task_id and run_id when SessionStateContributor reapplies serialized session state during checkpoint recovery.\n\nThis preserves the identifiers emitted by serialize(), avoids stale session metadata after restore, and adds a regression test that exercises the direct deserialize path alongside the existing checkpoint defaults coverage. --- dare_framework/checkpoint/defaults.py | 4 ++++ tests/unit/test_checkpoint_defaults.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py index 8abfdf5f..e1624fe7 100644 --- a/dare_framework/checkpoint/defaults.py +++ b/dare_framework/checkpoint/defaults.py @@ -172,6 +172,10 @@ def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: return if "current_milestone_idx" in payload and hasattr(state, "current_milestone_idx"): state.current_milestone_idx = payload["current_milestone_idx"] + if "task_id" in payload and hasattr(state, "task_id"): + state.task_id = payload["task_id"] + if "run_id" in payload and hasattr(state, "run_id"): + state.run_id = payload["run_id"] class SessionContextContributor: diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py index cb6854e9..39a02203 100644 --- a/tests/unit/test_checkpoint_defaults.py +++ b/tests/unit/test_checkpoint_defaults.py @@ -38,6 +38,13 @@ class _DummyCtx: session_context: _DummySessionContext | None +@dataclass +class _DummySessionState: + current_milestone_idx: int | None + task_id: str | None + run_id: str | None + + def test_session_context_contributor_preserves_serialized_config_dict() -> None: defaults = importlib.import_module("dare_framework.checkpoint.defaults") contributor = defaults.SessionContextContributor() @@ -55,6 +62,22 @@ def test_session_context_contributor_preserves_serialized_config_dict() -> None: assert payload["config"] == {"model": "gpt-4.1"} +def test_session_state_contributor_restores_task_and_run_ids() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.SessionStateContributor() + state = _DummySessionState(current_milestone_idx=0, task_id="task-old", run_id="run-old") + ctx = type("Ctx", (), {"session_state": state})() + + contributor.deserialize_and_apply( + {"current_milestone_idx": 3, "task_id": "task-new", "run_id": "run-new"}, + ctx, + ) + + assert state.current_milestone_idx == 3 + assert state.task_id == "task-new" + assert state.run_id == "run-new" + + def test_stm_contributor_roundtrip_uses_current_message_text_shape() -> None: defaults = importlib.import_module("dare_framework.checkpoint.defaults") contributor = defaults.StmContributor() From 6949af2667e98988bed99a4ed1030e51ba09ec5d Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 09:27:45 +0800 Subject: [PATCH 4/4] fix(checkpoint): preserve null stm message text on restore Preserve explicit None message text when StmContributor restores serialized STM payloads during checkpoint recovery.\n\nDifferentiate between checkpoints that intentionally saved text=None and legacy payloads that only exposed content, so tool/result messages round-trip without being coerced into empty strings. Add regression coverage for the null-text checkpoint case alongside the existing checkpoint defaults tests. --- dare_framework/checkpoint/defaults.py | 3 ++- tests/unit/test_checkpoint_defaults.py | 28 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py index e1624fe7..6e75e694 100644 --- a/dare_framework/checkpoint/defaults.py +++ b/dare_framework/checkpoint/defaults.py @@ -133,11 +133,12 @@ def deserialize_and_apply(self, payload: list[Any], ctx: Any) -> None: for item in payload or []: if not isinstance(item, dict): continue + restored_text = item["text"] if "text" in item else item.get("content", "") context.stm_add( Message( role=item.get("role", "user"), kind=item.get("kind", "chat"), - text=item.get("text") or item.get("content", ""), + text=restored_text, attachments=_clone_payload(item.get("attachments")) or [], data=_clone_payload(item.get("data")), name=item.get("name"), diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py index 39a02203..7a759174 100644 --- a/tests/unit/test_checkpoint_defaults.py +++ b/tests/unit/test_checkpoint_defaults.py @@ -155,6 +155,34 @@ def test_stm_contributor_roundtrip_preserves_typed_message_payloads() -> None: assert restored_messages[1].id == "assistant-1" +def test_stm_contributor_roundtrip_preserves_null_text_payloads() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.StmContributor() + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="tool", + kind=MessageKind.TOOL_RESULT, + text=None, + data={"tool_call_id": "tc_1", "output": {"ok": True}}, + id="tool-1", + ) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + + payload = contributor.serialize(source_ctx) + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + contributor.deserialize_and_apply(payload, restored_ctx) + + restored_messages = restored_context.stm_get() + assert len(restored_messages) == 1 + assert restored_messages[0].text is None + assert restored_messages[0].data == {"tool_call_id": "tc_1", "output": {"ok": True}} + + def test_checkpoint_save_snapshot_deep_copies_nested_message_metadata() -> None: defaults = importlib.import_module("dare_framework.checkpoint.defaults") store = defaults.MemoryCheckpointStore()