fix(persistence): handle corrupt JSON in _to_domain/_from_domain with CorruptRecordError #1221

Merged
HAL9000 merged 1 commits from bugfix/m5-json-decode-crash into master 2026-04-21 07:08:29 +00:00
5 changed files with 241 additions and 23 deletions
+4 -2
View File
@@ -21,7 +21,8 @@ rules:
include:
- src/
exclude:
- src/cleveragents/tool/wrapping.py
# Intentional controlled-sandbox exec in transform pipeline.
- src/cleveragents/tool/wrapping.py
- id: no-compile-exec
pattern: compile(..., ..., "exec")
@@ -34,7 +35,8 @@ rules:
include:
- src/
exclude:
- src/cleveragents/tool/wrapping.py
# Intentional controlled-sandbox compile in transform pipeline.
- src/cleveragents/tool/wrapping.py
- id: no-os-system
pattern: os.system(...)
@@ -1,7 +1,10 @@
"""Steps for TDD bug-capture scenario #989.
This scenario intentionally fails until the bugfix branch for #989 adds
corruption-specific handling around automation profile JSON decoding.
This file implements the Behave steps for bug #989 — corrupt JSON in
automation profile persistence. The ``@tdd_expected_fail`` tag has been
removed because the bugfix is included. These steps now verify the
corrected behavior: a ``CorruptRecordError`` is raised instead of a raw
``json.JSONDecodeError``.
"""
from __future__ import annotations
@@ -20,6 +23,7 @@ from cleveragents.infrastructure.database.models import (
)
from cleveragents.infrastructure.database.repositories import (
AutomationProfileRepository,
CorruptRecordError,
)
@@ -70,7 +74,7 @@ def step_given_corrupt_safety_json_row(context: Any) -> None:
@when("the corrupt automation profile is fetched by name for bug 989")
def step_when_fetch_corrupt_profile(context: Any) -> None:
"""Execute repository get path that currently leaks JSONDecodeError."""
"""Execute repository get path that wraps JSONDecodeError in CorruptRecordError."""
repo: AutomationProfileRepository = context.tdd_989_repo
try:
repo.get_by_name("bug-989-corrupt-json")
@@ -82,7 +86,7 @@ def step_when_fetch_corrupt_profile(context: Any) -> None:
"a corruption-specific domain error should be raised instead of JSONDecodeError for bug 989"
)
def step_then_domain_specific_error(context: Any) -> None:
"""Assert expected post-fix behavior (fails today while bug exists)."""
"""Assert that a CorruptRecordError is raised, not a raw JSONDecodeError."""
err = context.tdd_989_error
assert err is not None, (
"Expected a corruption-specific domain error, but no error was raised"
@@ -91,8 +95,141 @@ def step_then_domain_specific_error(context: Any) -> None:
"Expected a domain-specific corruption error, "
f"but raw JSONDecodeError leaked: {err}"
)
assert isinstance(err, CorruptRecordError), (
f"Expected CorruptRecordError, got {type(err).__name__}: {err}"
)
name_or_message = f"{type(err).__name__} {err}".lower()
assert "corrupt" in name_or_message, (
"Expected corruption-specific error semantics (type/message containing 'corrupt'), "
f"got {type(err).__name__}: {err}"
"Expected corruption-specific error semantics "
f"(type/message containing 'corrupt'), got {type(err).__name__}: {err}"
)
@given("an automation profile repository row with corrupt guards_json for bug 989")
def step_given_corrupt_guards_json_row(context: Any) -> None:
"""Persist a row with valid safety_json but corrupt guards_json."""
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, expire_on_commit=False, future=True)
now_iso = _now_iso()
with factory() as session:
session.add(
AutomationProfileModel(
name="bug-989-corrupt-guards",
description="Corrupt guards_json regression fixture",
schema_version="1.0",
decompose_task=0.0,
create_tool=0.0,
select_tool=0.0,
edit_code=0.0,
execute_command=0.0,
create_file=0.0,
delete_content=0.0,
access_network=0.0,
install_dependency=0.0,
modify_config=0.0,
approve_plan=0.0,
require_sandbox=True,
require_checkpoints=True,
allow_unsafe_tools=False,
safety_json=None,
# Deliberately malformed JSON
guards_json="{not valid json!",
created_at=now_iso,
updated_at=now_iso,
)
)
session.commit()
context.tdd_989_guards_repo = AutomationProfileRepository(factory)
context.tdd_989_guards_error = None
@when("the corrupt guards automation profile is fetched by name for bug 989")
def step_when_fetch_corrupt_guards_profile(context: Any) -> None:
"""Execute repository get path for corrupt guards_json."""
repo: AutomationProfileRepository = context.tdd_989_guards_repo
try:
repo.get_by_name("bug-989-corrupt-guards")
except Exception as exc:
context.tdd_989_guards_error = exc
@then("a corruption-specific domain error should be raised for guards_json for bug 989")
def step_then_guards_domain_specific_error(context: Any) -> None:
"""Assert CorruptRecordError is raised for corrupt guards_json."""
err = context.tdd_989_guards_error
assert err is not None, (
"Expected a CorruptRecordError for corrupt guards_json, but no error was raised"
)
assert isinstance(err, CorruptRecordError), (
f"Expected CorruptRecordError, got {type(err).__name__}: {err}"
)
assert err.field == "guards_json", (
f"Expected field='guards_json', got field='{err.field}'"
)
@given("a domain profile object with malformed safety data for bug 989")
def step_given_malformed_safety_profile(context: Any) -> None:
"""Create a mock profile with safety that cannot be serialized."""
class _BrokenSafety:
"""Safety object whose model_dump raises TypeError."""
require_sandbox: bool = True
require_checkpoints: bool = True
allow_unsafe_tools: bool = False
def model_dump(self) -> None:
raise TypeError("model_dump intentionally broken for bug 989 test")
class _FakeProfile:
"""Minimal profile stub with broken safety."""
name: str = "bug-989-malformed"
description: str = "malformed safety"
schema_version: str = "1.0"
decompose_task: float = 0.0
create_tool: float = 0.0
select_tool: float = 0.0
edit_code: float = 0.0
execute_command: float = 0.0
create_file: float = 0.0
delete_content: float = 0.0
access_network: float = 0.0
install_dependency: float = 0.0
modify_config: float = 0.0
approve_plan: float = 0.0
safety: _BrokenSafety = _BrokenSafety()
guards: None = None
context.tdd_989_malformed_profile = _FakeProfile()
context.tdd_989_from_domain_error = None
@when("_from_domain is called with the malformed profile for bug 989")
def step_when_from_domain_malformed(context: Any) -> None:
"""Call _from_domain with a profile that will fail serialization."""
try:
AutomationProfileRepository._from_domain(
context.tdd_989_malformed_profile, _now_iso()
)
except Exception as exc:
context.tdd_989_from_domain_error = exc
@then(
"a corruption-specific domain error should be raised for the serialization for bug 989"
)
def step_then_from_domain_error(context: Any) -> None:
"""Assert CorruptRecordError is raised when _from_domain fails to serialize."""
err = context.tdd_989_from_domain_error
assert err is not None, (
"Expected a CorruptRecordError from _from_domain, but no error was raised"
)
assert isinstance(err, CorruptRecordError), (
f"Expected CorruptRecordError, got {type(err).__name__}: {err}"
)
assert err.field == "safety", f"Expected field='safety', got field='{err.field}'"
@@ -1,14 +1,25 @@
@tdd_issue @tdd_issue_989 @tdd_expected_fail
@tdd_issue @tdd_issue_989
Feature: TDD Bug #989 — automation profile persistence crashes on corrupt JSON
As a developer reading persisted automation profiles
I want corrupt JSON payloads to be handled with a domain-specific error
So that callers do not receive a raw JSONDecodeError crash
# This test captures bug #989. It intentionally uses @tdd_expected_fail
# until the bugfix for #989 is merged. The underlying assertion currently
# fails (proving the bug exists) and the tag inversion makes CI pass.
# This test captures bug #989. The @tdd_expected_fail tag has been removed
# because the bugfix for #989 is included in the same commit. The test now
# verifies the corrected behavior: a CorruptRecordError is raised instead
# of a raw JSONDecodeError.
Scenario: Bug #989 — get_by_name should not leak JSONDecodeError for corrupt safety_json
Given an automation profile repository row with corrupt safety_json for bug 989
When the corrupt automation profile is fetched by name for bug 989
Then a corruption-specific domain error should be raised instead of JSONDecodeError for bug 989
Scenario: Bug #989 — corrupt guards_json also raises CorruptRecordError
Given an automation profile repository row with corrupt guards_json for bug 989
When the corrupt guards automation profile is fetched by name for bug 989
Then a corruption-specific domain error should be raised for guards_json for bug 989
Scenario: Bug #989 — _from_domain raises CorruptRecordError for malformed safety
Given a domain profile object with malformed safety data for bug 989
When _from_domain is called with the malformed profile for bug 989
Then a corruption-specific domain error should be raised for the serialization for bug 989
@@ -4367,6 +4367,30 @@ class AutomationProfileSchemaVersionError(DatabaseError):
"""Raised on schema_version mismatch during update."""
class CorruptRecordError(DatabaseError):
"""Raised when a persisted record contains corrupt or malformed data.
This wraps low-level deserialization errors (e.g. ``json.JSONDecodeError``,
``TypeError``) so that callers receive a domain-specific exception rather
than an opaque infrastructure error.
"""
def __init__(self, record_name: str, field: str, detail: str) -> None:
"""Initialise with context about the corrupt record.
Args:
record_name: Identifier of the record (e.g. profile name).
field: Name of the field that contained corrupt data.
detail: Description of the deserialization failure.
"""
super().__init__(
f"Corrupt data in record '{record_name}', field '{field}': {detail}"
)
self.record_name = record_name
self.field = field
self.detail = detail
# ---------------------------------------------------------------------------
# AutomationProfileRepository
# ---------------------------------------------------------------------------
@@ -4554,7 +4578,12 @@ class AutomationProfileRepository:
def _to_domain(
row: AutomationProfileModel,
) -> Any:
"""Convert an ORM row to an AutomationProfile domain object."""
"""Convert an ORM row to an AutomationProfile domain object.
Raises:
CorruptRecordError: If stored JSON fields are malformed or cannot
be deserialized into the expected domain objects.
"""
import json as _json
from cleveragents.domain.models.core.automation_profile import (
@@ -4563,13 +4592,22 @@ class AutomationProfileRepository:
)
from cleveragents.domain.models.core.safety_profile import SafetyProfile
profile_name = cast(str, row.name)
# Restore full safety from JSON if available, else fall back to
# legacy scalar columns for backward compatibility.
safety_json_str = (
cast(str | None, row.safety_json) if hasattr(row, "safety_json") else None
)
if safety_json_str:
safety = SafetyProfile(**_json.loads(safety_json_str))
try:
safety = SafetyProfile(**_json.loads(safety_json_str))
except (_json.JSONDecodeError, TypeError) as exc:
raise CorruptRecordError(
record_name=profile_name,
field="safety_json",
detail=str(exc),
) from exc
else:
safety = SafetyProfile(
require_sandbox=bool(row.require_sandbox),
@@ -4585,10 +4623,17 @@ class AutomationProfileRepository:
)
guards: AutomationGuard | None = None
if guards_json_str:
guards = AutomationGuard(**_json.loads(guards_json_str))
try:
guards = AutomationGuard(**_json.loads(guards_json_str))
except (_json.JSONDecodeError, TypeError) as exc:
raise CorruptRecordError(
record_name=profile_name,
field="guards_json",
detail=str(exc),
) from exc
return AutomationProfile(
name=cast(str, row.name),
name=profile_name,
description=cast(str, row.description) or "",
schema_version=cast(str, row.schema_version),
decompose_task=float(cast(float, row.decompose_task)),
@@ -4611,15 +4656,37 @@ class AutomationProfileRepository:
profile: Any,
now_iso: str,
) -> AutomationProfileModel:
"""Convert an AutomationProfile to an ORM row."""
"""Convert an AutomationProfile to an ORM row.
Raises:
CorruptRecordError: If the domain object cannot be serialized,
indicating the caller passed malformed data.
"""
import json as _json
safety_json = (
_json.dumps(profile.safety.model_dump()) if profile.safety else None
)
guards_json = (
_json.dumps(profile.guards.model_dump()) if profile.guards else None
)
profile_name: str = getattr(profile, "name", "<unknown>")
try:
safety_json = (
_json.dumps(profile.safety.model_dump()) if profile.safety else None
)
except (AttributeError, TypeError) as exc:
raise CorruptRecordError(
record_name=profile_name,
field="safety",
detail=str(exc),
) from exc
try:
guards_json = (
_json.dumps(profile.guards.model_dump()) if profile.guards else None
)
except (AttributeError, TypeError) as exc:
raise CorruptRecordError(
record_name=profile_name,
field="guards",
detail=str(exc),
) from exc
return AutomationProfileModel(
name=profile.name,
+1
View File
@@ -143,6 +143,7 @@ AutomationProfileRepository # noqa: B018, F821
AutomationProfileNotFoundError # noqa: B018, F821
DuplicateAutomationProfileError # noqa: B018, F821
AutomationProfileSchemaVersionError # noqa: B018, F821
CorruptRecordError # noqa: B018, F821
_LEVEL_TO_PROFILE # noqa: B018, F821
_resolve_profile_for_plan # noqa: B018, F821
default_automation_profile # noqa: B018, F821