feat: reconcile legacy message identities
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
This commit is contained in:
tegwick 2026-08-23 11:59:52 +02:00
parent ab936a1e98
commit ed3feed310
13 changed files with 693 additions and 10 deletions

View file

@ -0,0 +1,136 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from hub_core.models.agent_message import AgentMessage
from hub_core.models.message_identity_alias import MessageIdentityAlias
class MessageIdentityError(ValueError):
"""Base error for governed message identity operations."""
class MessageIdentityConflict(MessageIdentityError):
"""An alias is already bound to a different canonical message."""
class CanonicalMessageNotFound(MessageIdentityError):
"""The proposed canonical message does not exist."""
@dataclass(frozen=True)
class MessageAliasRegistration:
alias: str
message_id: uuid.UUID
created: bool
source: str
reason: str
created_by: str
def canonical_message_id(reference: str) -> uuid.UUID | None:
"""Return a UUID only when ``reference`` is already a complete UUID."""
try:
return uuid.UUID(reference)
except (AttributeError, TypeError, ValueError):
return None
async def resolve_message_reference(
session: AsyncSession,
reference: str,
*,
alias_model: type[MessageIdentityAlias] | None = MessageIdentityAlias,
) -> uuid.UUID | None:
"""Resolve a canonical UUID or one exact, explicitly registered alias."""
canonical = canonical_message_id(reference)
if canonical is not None:
return canonical
if alias_model is None:
return None
alias = await session.get(alias_model, reference)
return alias.message_id if alias is not None else None
async def register_message_alias(
session: AsyncSession,
*,
alias: str,
message_id: uuid.UUID,
source: str,
reason: str,
created_by: str,
message_model: type[AgentMessage] = AgentMessage,
alias_model: type[MessageIdentityAlias] = MessageIdentityAlias,
) -> MessageAliasRegistration:
"""Register one attributable alias without changing the message row."""
_validate_alias(alias)
source = _required_text("source", source, 255)
reason = _required_text("reason", reason, None)
created_by = _required_text("created_by", created_by, 100)
message = await session.get(message_model, message_id)
if message is None:
raise CanonicalMessageNotFound(f"Canonical message {message_id} not found")
existing: Any = await session.get(alias_model, alias)
if existing is not None:
if existing.message_id != message_id:
raise MessageIdentityConflict(
f"Alias {alias!r} is already mapped to {existing.message_id}"
)
return MessageAliasRegistration(
alias=existing.alias,
message_id=existing.message_id,
created=False,
source=existing.source,
reason=existing.reason,
created_by=existing.created_by,
)
record = alias_model(
alias=alias,
message_id=message_id,
source=source,
reason=reason,
created_by=created_by,
)
session.add(record)
await session.commit()
return MessageAliasRegistration(
alias=alias,
message_id=message_id,
created=True,
source=source,
reason=reason,
created_by=created_by,
)
def _validate_alias(alias: str) -> None:
if not isinstance(alias, str) or not alias:
raise MessageIdentityError("alias must be a non-empty string")
if alias != alias.strip():
raise MessageIdentityError("alias must not contain leading or trailing whitespace")
if len(alias) > 255:
raise MessageIdentityError("alias must be at most 255 characters")
if "/" in alias:
raise MessageIdentityError("alias must not contain a path separator")
if canonical_message_id(alias) is not None:
raise MessageIdentityError("canonical UUIDs cannot be registered as aliases")
def _required_text(name: str, value: str, maximum: int | None) -> str:
if not isinstance(value, str) or not value.strip():
raise MessageIdentityError(f"{name} must be a non-empty string")
value = value.strip()
if maximum is not None and len(value) > maximum:
raise MessageIdentityError(f"{name} must be at most {maximum} characters")
return value

View file

@ -0,0 +1,51 @@
"""governed legacy message identity aliases
Revision ID: 0005_message_identity_aliases
Revises: 0004_workload_projection
Create Date: 2026-08-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0005_message_identity_aliases"
down_revision: Union[str, None] = "0004_workload_projection"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"agent_message_identity_aliases",
sa.Column("alias", sa.String(255), primary_key=True),
sa.Column(
"message_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("agent_messages.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("source", sa.String(255), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("created_by", sa.String(100), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
op.create_index(
"ix_agent_message_identity_aliases_message_id",
"agent_message_identity_aliases",
["message_id"],
)
def downgrade() -> None:
op.drop_index(
"ix_agent_message_identity_aliases_message_id",
table_name="agent_message_identity_aliases",
)
op.drop_table("agent_message_identity_aliases")

View file

@ -4,6 +4,7 @@ from hub_core.models.capability_catalog import CapabilityCatalog
from hub_core.models.capability_request import CapabilityRequest
from hub_core.models.domain import Domain
from hub_core.models.managed_repo import ManagedRepo
from hub_core.models.message_identity_alias import MessageIdentityAlias
from hub_core.models.progress_event import ProgressEvent
from hub_core.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot
@ -14,6 +15,7 @@ __all__ = [
"CapabilityRequest",
"Domain",
"ManagedRepo",
"MessageIdentityAlias",
"ProgressEvent",
"TPSCCatalog",
"TPSCEntry",

View file

@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, text
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -32,7 +32,7 @@ class AgentMessage(Base):
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=text("now()"),
server_default=func.now(),
nullable=False,
)

View file

@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from hub_core.models.base import Base
class MessageIdentityAlias(Base):
"""Explicit mapping from an historical reference to a canonical message."""
__tablename__ = "agent_message_identity_aliases"
alias: Mapped[str] = mapped_column(String(255), primary_key=True)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agent_messages.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
source: Mapped[str] = mapped_column(String(255), nullable=False)
reason: Mapped[str] = mapped_column(Text, nullable=False)
created_by: Mapped[str] = mapped_column(String(100), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)

View file

@ -1,4 +1,3 @@
import uuid
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
@ -8,6 +7,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from hub_core.models.agent_message import AgentMessage
from hub_core.models.message_identity_alias import MessageIdentityAlias
from hub_core.message_identity import resolve_message_reference
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
@ -15,13 +16,27 @@ def create_messages_router(
get_session: Callable[..., AsyncSession],
*,
message_model: type[AgentMessage] = AgentMessage,
message_alias_model: type[MessageIdentityAlias] | None = MessageIdentityAlias,
) -> APIRouter:
router = APIRouter(prefix="/messages", tags=["messages"])
async def _get_message(message_id: uuid.UUID, session: AsyncSession) -> Any:
async def _get_message(message_reference: str, session: AsyncSession) -> Any:
message_id = await resolve_message_reference(
session,
message_reference,
alias_model=message_alias_model,
)
if message_id is None:
raise HTTPException(
status_code=404,
detail=f"Message reference {message_reference!r} not found",
)
msg = await session.get(message_model, message_id)
if msg is None:
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
raise HTTPException(
status_code=404,
detail=f"Message reference {message_reference!r} not found",
)
return msg
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
@ -62,18 +77,29 @@ def create_messages_router(
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
async def get_thread(
thread_id: uuid.UUID,
thread_id: str,
session: AsyncSession = Depends(get_session),
) -> list[Any]:
resolved_thread_id = await resolve_message_reference(
session,
thread_id,
alias_model=message_alias_model,
)
if resolved_thread_id is None:
raise HTTPException(
status_code=404,
detail=f"Message reference {thread_id!r} not found",
)
q = select(message_model).where(
(message_model.id == thread_id) | (message_model.thread_id == thread_id)
(message_model.id == resolved_thread_id)
| (message_model.thread_id == resolved_thread_id)
).order_by(message_model.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.patch("/{message_id}/read", response_model=MessageRead)
async def mark_read(
message_id: uuid.UUID,
message_id: str,
session: AsyncSession = Depends(get_session),
) -> Any:
msg = await _get_message(message_id, session)
@ -85,7 +111,7 @@ def create_messages_router(
@router.patch("/{message_id}/archive", response_model=MessageRead)
async def archive_message(
message_id: uuid.UUID,
message_id: str,
session: AsyncSession = Depends(get_session),
) -> Any:
msg = await _get_message(message_id, session)
@ -98,7 +124,7 @@ def create_messages_router(
@router.post("/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def reply_to_message(
message_id: uuid.UUID,
message_id: str,
body: MessageReply,
session: AsyncSession = Depends(get_session),
) -> Any:

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import json
import uuid
from importlib.resources import files
from pathlib import Path
from typing import Sequence
@ -44,6 +45,30 @@ def build_parser(settings: RuntimeSettings | None = None) -> argparse.ArgumentPa
migration_export.add_argument("--source-revision")
migration_export.add_argument("--output", type=Path, required=True)
message_alias = commands.add_parser(
"message-alias",
help="Manage explicit historical message identity aliases",
)
message_alias_commands = message_alias.add_subparsers(
dest="message_alias_command",
required=True,
)
message_alias_register = message_alias_commands.add_parser(
"register",
help="Bind one exact historical reference to an existing canonical message",
)
message_alias_register.add_argument("--alias", required=True)
message_alias_register.add_argument("--message-id", type=uuid.UUID, required=True)
message_alias_register.add_argument("--source", required=True)
message_alias_register.add_argument("--reason", required=True)
message_alias_register.add_argument("--created-by", required=True)
message_alias_register.add_argument("--database-url", default=resolved.database_url)
message_alias_register.add_argument(
"--confirm",
action="store_true",
help="Confirm the attributable database write",
)
conformance = commands.add_parser(
"conformance",
help="Run the implemented Tier 2/3 profile against an HTTP runtime",
@ -71,6 +96,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return 0
if args.command == "migration":
return _run_migration(args)
if args.command == "message-alias":
return _run_message_alias(args)
if args.command == "conformance":
return _run_conformance(args.base_url, args.timeout, args.as_json)
raise AssertionError(f"Unhandled command {args.command}")
@ -147,5 +174,57 @@ def _run_migration(args: argparse.Namespace) -> int:
return 0 if report.get("ok", True) else 1
def _run_message_alias(args: argparse.Namespace) -> int:
import asyncio
from dataclasses import asdict
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from hub_core.message_identity import MessageIdentityError, register_message_alias
if args.message_alias_command != "register":
raise AssertionError(f"Unhandled message alias command {args.message_alias_command}")
if not args.confirm:
raise SystemExit("message-alias register requires --confirm")
if not args.database_url:
raise SystemExit(
"message-alias register requires --database-url or HUB_CORE_DATABASE_URL"
)
async def run() -> dict:
engine = create_async_engine(_async_database_url(args.database_url))
sessions = async_sessionmaker(engine, expire_on_commit=False)
try:
async with sessions() as session:
result = await register_message_alias(
session,
alias=args.alias,
message_id=args.message_id,
source=args.source,
reason=args.reason,
created_by=args.created_by,
)
report = asdict(result)
report["message_id"] = str(result.message_id)
report["ok"] = True
return report
finally:
await engine.dispose()
try:
report = asyncio.run(run())
except MessageIdentityError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, sort_keys=True))
return 1
print(json.dumps(report, indent=2, sort_keys=True))
return 0
def _sync_database_url(database_url: str) -> str:
return database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
def _async_database_url(database_url: str) -> str:
if database_url.startswith("postgresql://"):
return database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
return database_url.replace("postgresql+psycopg2://", "postgresql+asyncpg://", 1)