feat: reconcile legacy message identities
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
This commit is contained in:
parent
ab936a1e98
commit
ed3feed310
13 changed files with 693 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
56
docs/message-identity-reconciliation.md
Normal file
56
docs/message-identity-reconciliation.md
Normal file
|
|
@ -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.
|
||||
136
hub_core/message_identity.py
Normal file
136
hub_core/message_identity.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
30
hub_core/models/message_identity_alias.py
Normal file
30
hub_core/models/message_identity_alias.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
166
tests/test_message_identity.py
Normal file
166
tests/test_message_identity.py
Normal file
|
|
@ -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())
|
||||
128
workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md
Normal file
128
workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue