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

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

View file

@ -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.

View file

@ -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 |

View 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View 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())

View 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