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.
|
progress, capability, and TPSC catalog/snapshot/report endpoints.
|
||||||
- Canonical FOS §10 risk and alert event types with `/progress/risks` and
|
- Canonical FOS §10 risk and alert event types with `/progress/risks` and
|
||||||
`/progress/alerts` read views.
|
`/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
|
- Shared utility helpers for slugs, pagination, repo path resolution, and
|
||||||
trailing-slash path normalization.
|
trailing-slash path normalization.
|
||||||
- Alembic templates plus an initial core-schema migration for hub adopters.
|
- 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-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-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-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-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-T02 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md |
|
||||||
| task | HUB-WP-0001-T03 | 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-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-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-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.capability_request import CapabilityRequest
|
||||||
from hub_core.models.domain import Domain
|
from hub_core.models.domain import Domain
|
||||||
from hub_core.models.managed_repo import ManagedRepo
|
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.progress_event import ProgressEvent
|
||||||
from hub_core.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot
|
from hub_core.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ __all__ = [
|
||||||
"CapabilityRequest",
|
"CapabilityRequest",
|
||||||
"Domain",
|
"Domain",
|
||||||
"ManagedRepo",
|
"ManagedRepo",
|
||||||
|
"MessageIdentityAlias",
|
||||||
"ProgressEvent",
|
"ProgressEvent",
|
||||||
"TPSCCatalog",
|
"TPSCCatalog",
|
||||||
"TPSCEntry",
|
"TPSCEntry",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ class AgentMessage(Base):
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=text("now()"),
|
server_default=func.now(),
|
||||||
nullable=False,
|
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 collections.abc import Callable
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -8,6 +7,8 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from hub_core.models.agent_message import AgentMessage
|
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
|
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,13 +16,27 @@ def create_messages_router(
|
||||||
get_session: Callable[..., AsyncSession],
|
get_session: Callable[..., AsyncSession],
|
||||||
*,
|
*,
|
||||||
message_model: type[AgentMessage] = AgentMessage,
|
message_model: type[AgentMessage] = AgentMessage,
|
||||||
|
message_alias_model: type[MessageIdentityAlias] | None = MessageIdentityAlias,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
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)
|
msg = await session.get(message_model, message_id)
|
||||||
if msg is None:
|
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
|
return msg
|
||||||
|
|
||||||
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
@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])
|
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
||||||
async def get_thread(
|
async def get_thread(
|
||||||
thread_id: uuid.UUID,
|
thread_id: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[Any]:
|
) -> 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(
|
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)
|
).order_by(message_model.created_at)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@router.patch("/{message_id}/read", response_model=MessageRead)
|
@router.patch("/{message_id}/read", response_model=MessageRead)
|
||||||
async def mark_read(
|
async def mark_read(
|
||||||
message_id: uuid.UUID,
|
message_id: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
msg = await _get_message(message_id, session)
|
msg = await _get_message(message_id, session)
|
||||||
|
|
@ -85,7 +111,7 @@ def create_messages_router(
|
||||||
|
|
||||||
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
||||||
async def archive_message(
|
async def archive_message(
|
||||||
message_id: uuid.UUID,
|
message_id: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
msg = await _get_message(message_id, session)
|
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)
|
@router.post("/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def reply_to_message(
|
async def reply_to_message(
|
||||||
message_id: uuid.UUID,
|
message_id: str,
|
||||||
body: MessageReply,
|
body: MessageReply,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
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("--source-revision")
|
||||||
migration_export.add_argument("--output", type=Path, required=True)
|
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 = commands.add_parser(
|
||||||
"conformance",
|
"conformance",
|
||||||
help="Run the implemented Tier 2/3 profile against an HTTP runtime",
|
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
|
return 0
|
||||||
if args.command == "migration":
|
if args.command == "migration":
|
||||||
return _run_migration(args)
|
return _run_migration(args)
|
||||||
|
if args.command == "message-alias":
|
||||||
|
return _run_message_alias(args)
|
||||||
if args.command == "conformance":
|
if args.command == "conformance":
|
||||||
return _run_conformance(args.base_url, args.timeout, args.as_json)
|
return _run_conformance(args.base_url, args.timeout, args.as_json)
|
||||||
raise AssertionError(f"Unhandled command {args.command}")
|
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
|
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:
|
def _sync_database_url(database_url: str) -> str:
|
||||||
return database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
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:
|
def test_core_tables_are_registered() -> None:
|
||||||
assert set(Base.metadata.tables) == {
|
assert set(Base.metadata.tables) == {
|
||||||
|
"agent_message_identity_aliases",
|
||||||
"agent_messages",
|
"agent_messages",
|
||||||
"capability_catalog",
|
"capability_catalog",
|
||||||
"capability_requests",
|
"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