From ed3feed3104f3c9644ad56b7eb98abb74dd8169d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 23 Aug 2026 11:59:52 +0200 Subject: [PATCH] feat: reconcile legacy message identities Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49 --- README.md | 2 + WORK-RECORDS.md | 6 + docs/message-identity-reconciliation.md | 56 ++++++ hub_core/message_identity.py | 136 ++++++++++++++ .../versions/0005_message_identity_aliases.py | 51 ++++++ hub_core/models/__init__.py | 2 + hub_core/models/agent_message.py | 4 +- hub_core/models/message_identity_alias.py | 30 ++++ hub_core/routers/messages.py | 42 ++++- hub_core/runtime/cli.py | 79 +++++++++ tests/test_imports.py | 1 + tests/test_message_identity.py | 166 ++++++++++++++++++ ...-legacy-message-identity-reconciliation.md | 128 ++++++++++++++ 13 files changed, 693 insertions(+), 10 deletions(-) create mode 100644 docs/message-identity-reconciliation.md create mode 100644 hub_core/message_identity.py create mode 100644 hub_core/migrations/versions/0005_message_identity_aliases.py create mode 100644 hub_core/models/message_identity_alias.py create mode 100644 tests/test_message_identity.py create mode 100644 workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md diff --git a/README.md b/README.md index a371c95..9df507d 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ are tracked in `HUB-WP-0006`. progress, capability, and TPSC catalog/snapshot/report endpoints. - Canonical FOS §10 risk and alert event types with `/progress/risks` and `/progress/alerts` read views. +- Explicit, attributable legacy message identity aliases that preserve the + canonical message row and never guess malformed references. - Shared utility helpers for slugs, pagination, repo path resolution, and trailing-slash path normalization. - Alembic templates plus an initial core-schema migration for hub adopters. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index a56a7d1..e716565 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -15,6 +15,7 @@ | workplan | HUB-WP-0005 | finished | — | workplans/HUB-WP-0005-core-hub-absorption-execution.md | | workplan | HUB-WP-0006 | active | — | workplans/HUB-WP-0006-repository-classification-navigation.md | | workplan | HUB-WP-0007 | finished | — | workplans/HUB-WP-0007-workload-projection-transport.md | +| workplan | HUB-WP-0008 | active | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | | task | HUB-WP-0001-T01 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md | | task | HUB-WP-0001-T02 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md | | task | HUB-WP-0001-T03 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md | @@ -50,3 +51,8 @@ | task | HUB-WP-0007-T03 | done | — | workplans/HUB-WP-0007-workload-projection-transport.md | | task | HUB-WP-0007-T04 | done | — | workplans/HUB-WP-0007-workload-projection-transport.md | | task | HUB-WP-0007-T05 | done | — | workplans/HUB-WP-0007-workload-projection-transport.md | +| task | HUB-WP-0008-T01 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | +| task | HUB-WP-0008-T02 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | +| task | HUB-WP-0008-T03 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | +| task | HUB-WP-0008-T04 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | +| task | HUB-WP-0008-T05 | wait | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | diff --git a/docs/message-identity-reconciliation.md b/docs/message-identity-reconciliation.md new file mode 100644 index 0000000..89c158e --- /dev/null +++ b/docs/message-identity-reconciliation.md @@ -0,0 +1,56 @@ +# Message identity reconciliation + +Hub-core supports explicit aliases for historical message references that are +not complete UUIDs. An alias points to one existing canonical message and +records its source, reason, creator, and creation time. Registration never +changes the canonical message row, body, thread, or timestamps. + +Aliases are deliberately not inferred. A missing character, transposition, or +similar-looking UUID remains unresolved until an operator supplies an exact +mapping with provenance. Canonical UUIDs cannot be registered as aliases, and +an existing alias cannot be rebound to a different message. + +## CUST-IN-0012 finding + +The production message is intact under canonical ID +`0b8dd0bf-41d1-47da-96ac-40e443c32e47`. It was created at +`2026-08-20T06:09:01.943176Z` and marked read at +`2026-08-22T23:16:34.490006Z`. The residual reference +`0b8dd0bf-41d-47da-96ac-40e443c32e47` omitted the final `1` in the second +UUID group. This is a reference defect, not a malformed stored row. + +The governed mapping is therefore: + +| Historical reference | Canonical message ID | Source | +| --- | --- | --- | +| `0b8dd0bf-41d-47da-96ac-40e443c32e47` | `0b8dd0bf-41d1-47da-96ac-40e443c32e47` | `CUST-IN-0012` | + +## Applying a mapping + +New hub-core databases receive `agent_message_identity_aliases` through +migration `0005_message_identity_aliases`. Existing hosts with their own +Alembic history must port that single table migration into the host-owned +migration chain before updating the router package; they must not run the +entire hub-core migration history over pre-existing core tables. + +After the host migration, an attended operator can register this mapping: + +```bash +hub-core message-alias register \ + --alias 0b8dd0bf-41d-47da-96ac-40e443c32e47 \ + --message-id 0b8dd0bf-41d1-47da-96ac-40e443c32e47 \ + --source CUST-IN-0012 \ + --reason "Historical reference omitted one UUID character" \ + --created-by operator \ + --confirm +``` + +The database URL comes from `HUB_CORE_DATABASE_URL` unless `--database-url` is +provided. The command returns only the alias, canonical UUID, immutable +provenance, and whether a row was created. It does not return message content +or database credentials. Repeating the same mapping is idempotent; attempting +to rebind the alias fails. + +Once registered, normal message routes accept either the canonical UUID or the +exact alias. Responses always contain the canonical UUID. Unknown malformed +references return 404 and are never repaired heuristically. diff --git a/hub_core/message_identity.py b/hub_core/message_identity.py new file mode 100644 index 0000000..07cf27d --- /dev/null +++ b/hub_core/message_identity.py @@ -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 diff --git a/hub_core/migrations/versions/0005_message_identity_aliases.py b/hub_core/migrations/versions/0005_message_identity_aliases.py new file mode 100644 index 0000000..0c39612 --- /dev/null +++ b/hub_core/migrations/versions/0005_message_identity_aliases.py @@ -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") diff --git a/hub_core/models/__init__.py b/hub_core/models/__init__.py index 4d78e46..c76042a 100644 --- a/hub_core/models/__init__.py +++ b/hub_core/models/__init__.py @@ -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", diff --git a/hub_core/models/agent_message.py b/hub_core/models/agent_message.py index 8887503..7ba96fb 100644 --- a/hub_core/models/agent_message.py +++ b/hub_core/models/agent_message.py @@ -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, ) diff --git a/hub_core/models/message_identity_alias.py b/hub_core/models/message_identity_alias.py new file mode 100644 index 0000000..9f0e8a1 --- /dev/null +++ b/hub_core/models/message_identity_alias.py @@ -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, + ) diff --git a/hub_core/routers/messages.py b/hub_core/routers/messages.py index 054bde2..caf076c 100644 --- a/hub_core/routers/messages.py +++ b/hub_core/routers/messages.py @@ -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: diff --git a/hub_core/runtime/cli.py b/hub_core/runtime/cli.py index 145225d..9e6a3c6 100644 --- a/hub_core/runtime/cli.py +++ b/hub_core/runtime/cli.py @@ -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) diff --git a/tests/test_imports.py b/tests/test_imports.py index c2af3da..de8147a 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -50,6 +50,7 @@ from hub_core.schemas.tpsc import TPSCCatalogRead, TPSCGDPRReport, TPSCGDPRWarni def test_core_tables_are_registered() -> None: assert set(Base.metadata.tables) == { + "agent_message_identity_aliases", "agent_messages", "capability_catalog", "capability_requests", diff --git a/tests/test_message_identity.py b/tests/test_message_identity.py new file mode 100644 index 0000000..ddee11b --- /dev/null +++ b/tests/test_message_identity.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timezone + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from hub_core.message_identity import ( + MessageIdentityConflict, + MessageIdentityError, + register_message_alias, +) +from hub_core.models.agent_message import AgentMessage +from hub_core.models.message_identity_alias import MessageIdentityAlias +from hub_core.routers.messages import create_messages_router + + +CANONICAL_ID = uuid.UUID("0b8dd0bf-41d1-47da-96ac-40e443c32e47") +MALFORMED_REFERENCE = "0b8dd0bf-41d-47da-96ac-40e443c32e47" +CREATED_AT = datetime(2026, 8, 20, 6, 9, 1, tzinfo=timezone.utc) + + +def _database(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'messages.db'}") + sessions = async_sessionmaker(engine, expire_on_commit=False) + + async def prepare() -> None: + async with engine.begin() as connection: + await connection.run_sync(AgentMessage.__table__.create) + await connection.run_sync(MessageIdentityAlias.__table__.create) + async with sessions() as session: + session.add( + AgentMessage( + id=CANONICAL_ID, + from_agent="risk-nexus", + to_agent="the-custodian", + subject="Canon request", + body="Original body", + created_at=CREATED_AT, + ) + ) + await session.commit() + + asyncio.run(prepare()) + return engine, sessions + + +def _register(sessions, *, alias=MALFORMED_REFERENCE, message_id=CANONICAL_ID): + async def run(): + async with sessions() as session: + return await register_message_alias( + session, + alias=alias, + message_id=message_id, + source="CUST-IN-0012", + reason="Historical reference omitted one UUID character", + created_by="codex", + ) + + return asyncio.run(run()) + + +def _client(sessions) -> TestClient: + async def get_session(): + async with sessions() as session: + yield session + + app = FastAPI() + app.include_router(create_messages_router(get_session)) + return TestClient(app) + + +def test_alias_registration_is_explicit_attributable_and_idempotent(tmp_path) -> None: + engine, sessions = _database(tmp_path) + try: + first = _register(sessions) + second = _register(sessions) + + assert first.created is True + assert second.created is False + assert first.message_id == second.message_id == CANONICAL_ID + assert second.source == "CUST-IN-0012" + assert second.created_by == "codex" + finally: + asyncio.run(engine.dispose()) + + +def test_alias_registration_rejects_conflicts_and_canonical_uuid_shadowing(tmp_path) -> None: + engine, sessions = _database(tmp_path) + try: + _register(sessions) + other_id = uuid.uuid4() + + async def add_other() -> None: + async with sessions() as session: + session.add( + AgentMessage( + id=other_id, + from_agent="sender", + to_agent="recipient", + subject="Other", + body="Other body", + created_at=CREATED_AT, + ) + ) + await session.commit() + + asyncio.run(add_other()) + with pytest.raises(MessageIdentityConflict): + _register(sessions, message_id=other_id) + with pytest.raises(MessageIdentityError, match="canonical UUID"): + _register(sessions, alias=str(other_id), message_id=other_id) + finally: + asyncio.run(engine.dispose()) + + +def test_message_operations_resolve_alias_and_return_canonical_identity(tmp_path) -> None: + engine, sessions = _database(tmp_path) + try: + _register(sessions) + client = _client(sessions) + before = client.get(f"/messages/thread/{CANONICAL_ID}") + assert before.status_code == 200 + original_created_at = before.json()[0]["created_at"] + + marked = client.patch(f"/messages/{MALFORMED_REFERENCE}/read") + assert marked.status_code == 200 + assert marked.json()["id"] == str(CANONICAL_ID) + assert marked.json()["body"] == "Original body" + assert marked.json()["created_at"] == original_created_at + assert marked.json()["read_at"] is not None + + reply = client.post( + f"/messages/{MALFORMED_REFERENCE}/reply", + json={"from_agent": "the-custodian", "body": "Handled"}, + ) + assert reply.status_code == 201 + assert reply.json()["thread_id"] == str(CANONICAL_ID) + + thread = client.get(f"/messages/thread/{MALFORMED_REFERENCE}") + assert thread.status_code == 200 + assert [row["id"] for row in thread.json()][0] == str(CANONICAL_ID) + assert [row["body"] for row in thread.json()] == ["Original body", "Handled"] + + archived = client.patch(f"/messages/{MALFORMED_REFERENCE}/archive") + assert archived.status_code == 200 + assert archived.json()["id"] == str(CANONICAL_ID) + assert archived.json()["archived_at"] is not None + finally: + asyncio.run(engine.dispose()) + + +def test_unknown_malformed_reference_is_not_guessed(tmp_path) -> None: + engine, sessions = _database(tmp_path) + try: + client = _client(sessions) + response = client.patch("/messages/not-a-registered-identity/read") + + assert response.status_code == 404 + assert "not-a-registered-identity" in response.json()["detail"] + finally: + asyncio.run(engine.dispose()) diff --git a/workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md b/workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md new file mode 100644 index 0000000..2df0b41 --- /dev/null +++ b/workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md @@ -0,0 +1,128 @@ +--- +id: HUB-WP-0008 +type: workplan +title: "Legacy message identity reconciliation" +domain: infotech +repo: hub-core +status: active +owner: codex +topic_slug: custodian +created: "2026-08-23" +updated: "2026-08-23" +related: + - CUST-WP-0063 + - CUST-IN-0012 +--- + +# Legacy message identity reconciliation + +## Goal + +Provide a governed, auditable way to resolve malformed historical message +references to their existing canonical message UUIDs. Preserve message body, +threading, chronology, and canonical identity; do not rewrite message rows or +silently guess aliases. + +## Establish the source record and repair boundary + +```task +id: HUB-WP-0008-T01 +status: done +priority: high +``` + +Reproduce `CUST-IN-0012`, locate the source message without database mutation, +and determine whether the defect is stored data or an external reference. +Record the canonical identity, chronology, current read state, and the +constraints that any repair must preserve. + +Completed 2026-08-23. The production list and preserved coordination evidence +show that the stored record is valid and unchanged at canonical ID +`0b8dd0bf-41d1-47da-96ac-40e443c32e47`, created at +`2026-08-20T06:09:01.943176Z`. The `CUST-IN-0012` reference omitted the final +`1` in the second UUID group. The canonical message was already marked read at +`2026-08-22T23:16:34.490006Z`; there is no malformed database row to rewrite. + +## Add durable explicit message aliases + +```task +id: HUB-WP-0008-T02 +status: done +priority: high +``` + +Add a core-schema alias record keyed by the exact historical reference and +pointing to one canonical message UUID. Require operator/migration provenance, +reject alias conflicts, and keep canonical UUID lookup unchanged. + +Completed 2026-08-23. `MessageIdentityAlias` and migration +`0005_message_identity_aliases` add a separate alias table with an exact text +primary key, restrictive foreign key to the canonical message, immutable +source/reason/creator provenance, and creation time. The registration service +rejects canonical UUID shadowing, whitespace/path ambiguity, absent canonical +messages, and conflicting rebinding; identical registration is idempotent. + +## Resolve aliases through message operations + +```task +id: HUB-WP-0008-T03 +status: done +priority: high +``` + +Allow read, archive, reply, and thread operations to resolve only explicitly +registered aliases. Unknown malformed identifiers remain rejected or not +found; no edit-distance or UUID-repair guessing is permitted. + +Completed 2026-08-23. The message router accepts string references at the +thread/read/archive/reply boundaries, resolves complete UUIDs directly, and +consults aliases only for non-UUID references. Every response continues to +carry the canonical message UUID. `hub-core message-alias register` provides +the attributable apply path and requires an explicit `--confirm`. + +## Prove preservation and failure behavior + +```task +id: HUB-WP-0008-T04 +status: done +priority: medium +``` + +Test canonical lookup, alias lookup, idempotent registration, conflicting +mapping rejection, unknown malformed references, and preservation of message +body, chronology, threading, and canonical response identity. + +Completed 2026-08-23. Four focused integration tests prove explicit and +idempotent registration, immutable provenance, conflicting mapping and UUID +shadow rejection, alias-based read/reply/thread/archive behavior, unchanged +body and creation time, canonical response identity, and 404 for an unknown +malformed reference. The full suite passes 104 tests; the wheel contains the +service, model, migration, router, and CLI surfaces. + +## Hand off the production reconciliation + +```task +id: HUB-WP-0008-T05 +status: wait +priority: high +``` + +Publish the canonical mapping for `CUST-IN-0012`, provide a value-safe +migration/apply procedure to the State Hub retirement owner, verify the +supported read transition, and notify Custodian with non-secret evidence. + +Local handoff material is complete in +`docs/message-identity-reconciliation.md`. Production registration remains +`wait`: State Hub owns a separate Alembic history and must port migration 0005 +into that chain before adopting the new router. The canonical message is +already read, so there is no urgent message mutation and no justification for +direct database access. After the host migration/deploy, register the exact +alias through the confirmed CLI and verify both canonical and alias reads. + +## Acceptance + +- [x] Source record and false/malformed reference are distinguished +- [x] Explicit aliases are durable, attributable, and conflict-safe +- [x] Message operations preserve the canonical response identity +- [x] No message content, chronology, or thread relationship is rewritten +- [ ] `CUST-IN-0012` closure evidence reaches Custodian