feat(persistence): enforce image reference storage contract

- Reject structured inline images on ORM and Core writes
- Route plugin run results through the media-aware journal
- Redact legacy inline images before merging run metadata
- Cover storage contracts and regressions on SQLite and PostgreSQL
This commit is contained in:
BiFangKNT
2026-10-18 12:26:09 +08:00
parent 1e5aeb6032
commit b73f66a234
17 changed files with 577 additions and 21 deletions
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...persistence.datetime_utils import as_naive_utc
from ...storage.media import MediaCache
from ...utils.inflight import inflight_hub
from ...utils.execution_lease import host_execution_lease
@@ -384,7 +385,7 @@ class RunLedgerStore:
if run is None or run.workspace_id != workspace_id or run.status in TERMINAL_STATUSES:
raise ValueError('Skill activation requires an active run in the current Workspace')
original = run.metadata_json
metadata = _json_loads(original, {})
metadata = MediaCache.redact_inline_images(_json_loads(original, {}))
bindings = metadata.setdefault('activated_skills', {})
if name in bindings:
return str(bindings[name])
@@ -439,7 +440,8 @@ class RunLedgerStore:
if cost is not None:
run.cost_json = _json_dumps(cost)
if metadata is not None:
existing_metadata = _json_loads(run.metadata_json, {})
# Discard legacy image bytes before merging; new metadata still uses the write contract.
existing_metadata = MediaCache.redact_inline_images(_json_loads(run.metadata_json, {}))
if isinstance(existing_metadata, dict):
existing_metadata.update(metadata)
run.metadata_json = _json_dumps(existing_metadata)
@@ -580,7 +580,7 @@ class MonitoringService(ExecutionDetailsMixin):
'user_id': _normalize_user_id(user_id),
'user_name': user_name,
'runner_name': runner_name,
'variables': variables,
'variables': self._sanitize_message_content(variables) if variables is not None else None,
'role': role,
'event_id': event_id,
'run_id': run_id,
@@ -888,7 +888,7 @@ class MonitoringService(ExecutionDetailsMixin):
if level is not None:
update_values['level'] = level
if variables is not None:
update_values['variables'] = variables
update_values['variables'] = self._sanitize_message_content(variables)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_monitoring.MonitoringMessage)
@@ -6,6 +6,7 @@ import datetime
import sqlalchemy
from ...persistence.media import MediaReferenceText
from .base import Base
@@ -107,7 +108,7 @@ class AgentRun(Base):
authorization_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Run-scoped authorization snapshot JSON."""
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Additional metadata JSON."""
__table_args__ = (
@@ -185,7 +186,7 @@ class AgentRunEvent(Base):
type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True)
"""Result event type."""
data_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
data_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Result event payload JSON."""
usage_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
@@ -197,7 +198,7 @@ class AgentRunEvent(Base):
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
"""Source that appended the event."""
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Additional metadata JSON."""
__table_args__ = (
@@ -6,6 +6,7 @@ import sqlalchemy
import datetime
from .base import Base
from ...persistence.media import MediaReferenceText
class EventLog(Base):
@@ -66,7 +67,7 @@ class EventLog(Base):
input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Brief summary of input (truncated text, max 1000 chars)."""
input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
input_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Full input JSON if reasonably sized (AgentInput as JSON string)."""
# Raw event reference
@@ -82,7 +83,7 @@ class EventLog(Base):
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
"""When this record was created."""
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Additional metadata as JSON string."""
__table_args__ = (sqlalchemy.Index('ix_event_log_media_ttl', 'workspace_id', 'created_at', 'id'),)
@@ -1,5 +1,6 @@
import sqlalchemy
from ...persistence.media import MediaReferenceText
from .base import Base
@@ -19,7 +20,7 @@ class MonitoringMessage(Base):
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
message_content = sqlalchemy.Column(MediaReferenceText, nullable=False)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
execution_owner_id = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True)
execution_lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True)
@@ -31,7 +32,7 @@ class MonitoringMessage(Base):
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
variables = sqlalchemy.Column(MediaReferenceText, nullable=True) # Query variables as JSON string
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
@@ -6,6 +6,7 @@ import sqlalchemy
import datetime
from .base import Base
from ...persistence.media import MediaReferenceText
class Transcript(Base):
@@ -48,11 +49,11 @@ class Transcript(Base):
content = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Text content summary (may be truncated for large messages, max 4000 chars)."""
content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
content_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Full structured content as JSON string (Message model dump)."""
# Attachment references
attachment_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
attachment_refs_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Attachment references as JSON string."""
# Sequence for cursor-based pagination
@@ -69,7 +70,7 @@ class Transcript(Base):
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
"""When this item was created."""
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Additional metadata as JSON string (sender_id, platform, etc.)."""
# Indexes
+46
View File
@@ -0,0 +1,46 @@
"""The storage contract for conversation payloads containing image references."""
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import Text
from sqlalchemy.engine import Dialect
from sqlalchemy.sql.operators import OperatorType
from sqlalchemy.types import TypeDecorator
class MediaReferenceText(TypeDecorator[str]):
"""Keep the existing TEXT format while rejecting structured inline images.
Writers must externalize retained images through MediaCache or redact
metadata-only images before binding a value. Both ORM and Core writes use
this contract; reads preserve historical values for incremental cleanup.
Plain text remains valid for fields such as monitoring message content.
"""
impl = Text
cache_ok = True
def process_bind_param(self, value: str | None, dialect: Dialect) -> str | None:
if value is None:
return None
try:
payload = json.loads(value)
except json.JSONDecodeError:
return value
# Model imports must not initialize the media cache's runtime dependencies.
from ..storage.media import MediaCache
if MediaCache.has_inline_images(payload):
raise ValueError(
'Inline images cannot be persisted; use MediaCache.externalize_chain_dump() '
'for retained content or MediaCache.redact_inline_images() for metadata'
)
return value
def coerce_compared_value(self, op: OperatorType | None, value: Any) -> Text:
# Legacy payloads must remain usable in searches and compare-and-swap updates.
return self.impl
+4 -2
View File
@@ -19,6 +19,8 @@ from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLO
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
from sqlalchemy.dialects.sqlite.dml import OnConflictDoUpdate as SQLiteOnConflictDoUpdate
from .media import MediaReferenceText
TENANT_SETTING = 'langbot.workspace_uuid'
ACCOUNT_SETTING = 'langbot.account_uuid'
@@ -292,7 +294,7 @@ def _validate_scoped_sql_type(
if type(sql_type) in {Vector, HALFVEC}:
return
if not type(sql_type).__module__.startswith('sqlalchemy.'):
if type(sql_type) is not MediaReferenceText and not type(sql_type).__module__.startswith('sqlalchemy.'):
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
for identifier_attribute in ('name', 'schema', 'collation'):
@@ -486,7 +488,7 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
monitoring_metadata = (
type(element.type) is sqlalchemy.JSON
and isinstance(source, sqlalchemy.Column)
and type(source.type) is sqlalchemy.Text
and type(source.type) in {sqlalchemy.Text, MediaReferenceText}
and getattr(getattr(source, 'table', None), 'name', None) in {'event_log', 'agent_run'}
and (
source.name == 'metadata_json' or (source.table.name == 'event_log' and source.name == 'input_json')
+8 -4
View File
@@ -469,12 +469,16 @@ def register(h):
if payload_error:
return handler.ActionResponse.error(message=payload_error)
event = await store.append_event(
from ..agent.runner.run_journal import AgentRunJournal
event = await AgentRunJournal(h.ap).append_run_result(
run_id=str(target_run_id),
sequence=sequence,
event_type=str(event_type),
data=event_payload,
usage=usage if isinstance(usage, dict) else None,
result_dict={
'type': str(event_type),
'data': event_payload,
'usage': usage if isinstance(usage, dict) else None,
},
source=str(data.get('source') or result.get('source') or 'runner'),
metadata=metadata,
)