Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions dare_framework/checkpoint/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"""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 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] = dict(payload)

def get(self, checkpoint_id: str) -> dict[str, Any] | None:
if checkpoint_id not in self._store:
return None
return dict(self._store[checkpoint_id])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deep-copy payloads returned from checkpoint store

MemoryCheckpointStore.get() returns only a shallow dict(...) copy, so nested structures remain shared with _store. In practice this corrupts checkpoints: after restore(), mutating nested message metadata in the restored context can mutate the stored snapshot, and a second restore returns the mutated state instead of the original checkpoint. Checkpoint snapshots should be immutable once saved, so this path needs a deep clone on read (and ideally on write as well).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handled in 5362ee5.

MemoryCheckpointStore now deep-copies payloads on both put() and get(), so nested checkpoint state cannot be mutated through restored objects or repeated reads. Added a regression in tests/unit/test_checkpoint_defaults.py that restores the same checkpoint twice and verifies mutating the first restored context does not affect the second restore.


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 []


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,
"text": m.text,
"name": m.name,
"metadata": dict(getattr(m, "metadata", {}) or {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deep-copy checkpoint metadata to keep snapshots stable

StmContributor.serialize deep-copies data and attachment metadata but only shallow-copies message metadata via dict(...). Nested metadata objects therefore stay aliased to live context objects, so mutating metadata after save() can silently mutate stored checkpoint contents and break point-in-time restore semantics. Checkpoint payload construction should deep-copy metadata the same way as other nested fields.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handled in f32e736.

StmContributor.serialize() now deep-copies message metadata the same way it already deep-copies attachment metadata and data, so saved checkpoint payloads are no longer aliased to live nested metadata objects. Added a regression in tests/unit/test_checkpoint_defaults.py that mutates the source message metadata after save() and verifies the restored checkpoint still contains the original nested value.

}
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"),
text=item.get("text") or item.get("content", ""),
name=item.get("name"),
metadata=dict(item.get("metadata") or {}),
Comment on lines +137 to +144

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve message kind/data in STM checkpoint restore

The new StmContributor restore path reconstructs each message with only role, text, name, and metadata, so any checkpoint containing non-chat STM entries (for example tool_call/tool_result messages with structured data or attachments) is silently downgraded to default chat messages and loses payload fields on restore. This corrupts checkpoint round-trips for realistic agent sessions that rely on typed message state after resume.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handled in 22cb1da.

StmContributor now serializes/restores kind, attachments, data, mark, and id in addition to the legacy text/name/metadata fields, so typed STM entries survive checkpoint round-trips instead of being downgraded to default chat messages. Added a regression in tests/unit/test_checkpoint_defaults.py that round-trips attachments plus a TOOL_CALL message with structured payload.

)
)


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"]
Comment on lines +173 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore task/run identifiers from session-state payload

SessionStateContributor.serialize() persists task_id and run_id, but deserialize_and_apply() only reapplies current_milestone_idx, so checkpoint restore loses the saved session identity fields. In any resume flow that expects full session-state round-tripping (for example, restoring into a fresh context object), the restored state will keep whatever task_id/run_id happened to be on the target object, which can desynchronize traceability and subsequent orchestration logic from the checkpoint that was actually loaded.

Useful? React with 👍 / 👎.



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",
]
6 changes: 3 additions & 3 deletions dare_framework/embedding/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions dare_framework/embedding/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Supported default embedding implementations."""

from dare_framework.embedding._internal.openai_embedding import OpenAIEmbeddingAdapter

__all__ = ["OpenAIEmbeddingAdapter"]
2 changes: 1 addition & 1 deletion dare_framework/event/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
5 changes: 5 additions & 0 deletions dare_framework/event/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Supported default event-log implementations."""

from dare_framework.event._internal.sqlite_event_log import DefaultEventLog, SQLiteEventLog

__all__ = ["SQLiteEventLog", "DefaultEventLog"]
2 changes: 1 addition & 1 deletion dare_framework/hook/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand Down
5 changes: 5 additions & 0 deletions dare_framework/hook/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Supported default extension-point implementation."""

from dare_framework.hook._internal.hook_extension_point import HookExtensionPoint

__all__ = ["HookExtensionPoint"]
3 changes: 1 addition & 2 deletions dare_framework/plan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions dare_framework/plan/defaults.py
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 5 additions & 2 deletions dare_framework/security/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""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,
SECURITY_POLICY_DENIED,
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

Expand Down
7 changes: 5 additions & 2 deletions dare_framework/security/impl/default_security_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -259,8 +262,8 @@ async def execute_safe(
return await _execute_callable(fn)


# Backward-compatible alias for legacy references.
DefaultSecurityBoundary = PolicySecurityBoundary
# Preserve historical facade behavior where `DefaultSecurityBoundary` is permissive.
DefaultSecurityBoundary = LegacyDefaultSecurityBoundary

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep config-based constructor on impl default boundary

Rebinding dare_framework.security.impl.DefaultSecurityBoundary to the legacy permissive class removes the from_config(...) classmethod that existed when this symbol aliased PolicySecurityBoundary, so callers that instantiate via DefaultSecurityBoundary.from_config(...) now fail with AttributeError. Because this commit positions impl as the public export layer for facade imports, this is a backward-incompatible API regression for existing consumers of that module path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handled in 22cb1da.

dare_framework.security.impl.DefaultSecurityBoundary is now a permissive compatibility class with a from_config(...) classmethod that delegates to PolicySecurityBoundary.from_config(...), preserving the old constructor contract for callers that instantiate through the impl module path. Added coverage in tests/unit/test_security_boundary.py to assert from_config(...) still returns a policy-configured boundary.



__all__ = [
Expand Down
14 changes: 7 additions & 7 deletions dare_framework/transport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions dare_framework/transport/adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Supported default transport channel adapters."""

from dare_framework.transport._internal import (
DefaultAgentChannel,
DirectClientChannel,
StdioClientChannel,
WebSocketClientChannel,
)

__all__ = [
"DefaultAgentChannel",
"DirectClientChannel",
"StdioClientChannel",
"WebSocketClientChannel",
]
5 changes: 5 additions & 0 deletions docs/design/Framework_MinSurface_Review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading