feat: make repository reads alias-aware
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
tegwick 2026-08-29 10:47:51 +02:00
parent 639b9aed08
commit 2e2ae1e5d0
19 changed files with 982 additions and 94 deletions

View file

@ -324,7 +324,7 @@
| task | STATE-WP-0085-T01 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T01 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T03 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T03 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T04 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T04 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T05 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T05 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T06 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T06 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T07 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T07 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |

View file

@ -1,7 +1,7 @@
import re import re
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import HTTPException from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -11,7 +11,6 @@ from api.models.agent_message import AgentMessage
from api.models.capability_catalog import CapabilityCatalog from api.models.capability_catalog import CapabilityCatalog
from api.models.capability_request import CapabilityRequest from api.models.capability_request import CapabilityRequest
from api.models.domain import Domain from api.models.domain import Domain
from api.models.managed_repo import ManagedRepo
from api.models.task import Task from api.models.task import Task
from api.services.suggestion_relevance import bump_matching_for_capability_request from api.services.suggestion_relevance import bump_matching_for_capability_request
from api.schemas.capability_request import ( from api.schemas.capability_request import (
@ -22,12 +21,15 @@ from api.schemas.capability_request import (
CapabilityRequestRead, CapabilityRequestRead,
CapabilityRequestReroute, CapabilityRequestReroute,
CapabilityRequestStatusPatch, CapabilityRequestStatusPatch,
CatalogCreate,
CatalogPatch,
CatalogRead,
) )
from hub_core.routers.capabilities import ( from hub_core.routers.capabilities import (
create_capability_catalog_router,
create_capability_request_read_router, create_capability_request_read_router,
create_capability_request_write_router, create_capability_request_write_router,
) )
from api.services.repository_aliases import resolve_repository_slug
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -394,12 +396,102 @@ def _check_transition(current: str, target: str) -> None:
) )
router = create_capability_catalog_router( router = APIRouter(tags=["capability-requests"])
get_session,
domain_model=Domain,
repo_model=ManagedRepo, async def _catalog_domain(slug: str, session: AsyncSession) -> Domain:
catalog_model=CapabilityCatalog, domain = (
await session.execute(select(Domain).where(Domain.slug == slug))
).scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain
@router.post(
"/capability-catalog/",
response_model=CatalogRead,
status_code=status.HTTP_201_CREATED,
) )
async def create_catalog_entry(
body: CatalogCreate,
session: AsyncSession = Depends(get_session),
) -> CapabilityCatalog:
domain = await _catalog_domain(body.domain, session)
repo_id = None
if body.repo_slug:
repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id
entry = CapabilityCatalog(
domain_id=domain.id,
repo_id=repo_id,
capability_type=body.capability_type,
title=body.title,
description=body.description,
keywords=body.keywords,
)
session.add(entry)
try:
await session.commit()
except Exception:
await session.rollback()
raise HTTPException(
status_code=409,
detail=(
f"Catalog entry '{body.title}' for type '{body.capability_type}' "
f"already exists in domain '{body.domain}'"
),
)
await session.refresh(entry)
return entry
@router.get("/capability-catalog/", response_model=list[CatalogRead])
async def list_catalog(
domain: str | None = Query(None),
capability_type: str | None = Query(None),
status_filter: str | None = Query(None, alias="status"),
repo_slug: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[CapabilityCatalog]:
query = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
if domain:
query = query.where(
CapabilityCatalog.domain_id == (await _catalog_domain(domain, session)).id
)
if capability_type:
query = query.where(CapabilityCatalog.capability_type == capability_type)
if repo_slug:
query = query.where(
CapabilityCatalog.repo_id
== (await resolve_repository_slug(session, repo_slug)).repo.id
)
if status_filter and status_filter != "all":
query = query.where(CapabilityCatalog.status == status_filter)
elif not status_filter:
query = query.where(CapabilityCatalog.status == "active")
return list((await session.execute(query)).scalars().all())
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
async def patch_catalog_entry(
entry_id: uuid.UUID,
body: CatalogPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityCatalog:
entry = await session.get(CapabilityCatalog, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
if body.repo_slug is not None:
entry.repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id
for field in ("description", "keywords", "status"):
value = getattr(body, field)
if value is not None:
setattr(entry, field, value)
await session.commit()
await session.refresh(entry)
return entry
router.include_router( router.include_router(
create_capability_request_read_router( create_capability_request_read_router(
get_session, get_session,

View file

@ -23,6 +23,7 @@ from api.services.fabric_graph import (
record_fabric_graph_error, record_fabric_graph_error,
split_graph_ingest_body, split_graph_ingest_body,
) )
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/fabric", tags=["fabric"]) router = APIRouter(prefix="/fabric", tags=["fabric"])
@ -157,7 +158,12 @@ async def list_graph_nodes(
if domain: if domain:
query = query.where(FabricGraphNode.domain_slug == domain) query = query.where(FabricGraphNode.domain_slug == domain)
if repo: if repo:
query = query.where(FabricGraphNode.repo_slug == repo) resolution = await resolve_repository_slug(session, repo, required=False)
query = query.where(
FabricGraphNode.repo_slug.in_(
resolution.slug_values if resolution else (repo,)
)
)
if canonical_category: if canonical_category:
query = query.where(FabricGraphNode.canon_category == canonical_category) query = query.where(FabricGraphNode.canon_category == canonical_category)
if fabric_id: if fabric_id:

View file

@ -8,13 +8,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.agent_message import AgentMessage from api.models.agent_message import AgentMessage
from api.models.interface_change import InterfaceChange from api.models.interface_change import InterfaceChange
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent from api.models.progress_event import ProgressEvent
from api.schemas.interface_change import ( from api.schemas.interface_change import (
InterfaceChangeCreate, InterfaceChangeCreate,
InterfaceChangePatch, InterfaceChangePatch,
InterfaceChangeRead, InterfaceChangeRead,
) )
from api.services.repository_aliases import (
affected_slug_predicate,
canonicalize_repository_slug,
resolve_repository_slug,
)
router = APIRouter(prefix="/interface-changes", tags=["interface-changes"]) router = APIRouter(prefix="/interface-changes", tags=["interface-changes"])
@ -32,7 +36,12 @@ async def create_interface_change(
if body.change_type not in _VALID_CHANGE_TYPES: if body.change_type not in _VALID_CHANGE_TYPES:
raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}") raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}")
repo = await _repo_by_slug(body.repo_slug, session) resolution = await resolve_repository_slug(session, body.repo_slug)
repo = resolution.repo
affected_repo_slugs = [
await canonicalize_repository_slug(session, slug)
for slug in body.affected_repo_slugs
]
change = InterfaceChange( change = InterfaceChange(
repo_id=repo.id, repo_id=repo.id,
interface_type=body.interface_type, interface_type=body.interface_type,
@ -40,7 +49,7 @@ async def create_interface_change(
title=body.title, title=body.title,
description=body.description, description=body.description,
affected_paths=body.affected_paths, affected_paths=body.affected_paths,
affected_repo_slugs=body.affected_repo_slugs, affected_repo_slugs=affected_repo_slugs,
planned_for=body.planned_for, planned_for=body.planned_for,
author=body.author, author=body.author,
status="draft", status="draft",
@ -68,7 +77,11 @@ async def list_interface_changes(
if change_type: if change_type:
q = q.where(InterfaceChange.change_type == change_type) q = q.where(InterfaceChange.change_type == change_type)
if affected_repo: if affected_repo:
q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo])) resolution = await resolve_repository_slug(
session, affected_repo, required=False
)
values = resolution.slug_values if resolution else (affected_repo,)
q = q.where(affected_slug_predicate(InterfaceChange.affected_repo_slugs, values))
result = await session.execute(q) result = await session.execute(q)
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()] return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
@ -94,7 +107,13 @@ async def patch_interface_change(
status_code=409, status_code=409,
detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.", detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.",
) )
for field, value in body.model_dump(exclude_unset=True).items(): payload = body.model_dump(exclude_unset=True)
if payload.get("affected_repo_slugs") is not None:
payload["affected_repo_slugs"] = [
await canonicalize_repository_slug(session, slug)
for slug in payload["affected_repo_slugs"]
]
for field, value in payload.items():
setattr(change, field, value) setattr(change, field, value)
await session.commit() await session.commit()
await session.refresh(change) await session.refresh(change)
@ -119,12 +138,13 @@ async def publish_interface_change(
# Send inbox notifications to agents of affected repos # Send inbox notifications to agents of affected repos
affected = change.affected_repo_slugs or [] affected = change.affected_repo_slugs or []
for slug in affected: for slug in affected:
target_slug = await canonicalize_repository_slug(session, slug)
paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description" paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description"
if len(change.affected_paths) > 5: if len(change.affected_paths) > 5:
paths_summary += f" (+{len(change.affected_paths) - 5} more)" paths_summary += f" (+{len(change.affected_paths) - 5} more)"
msg = AgentMessage( msg = AgentMessage(
from_agent=change.repo.slug, from_agent=change.repo.slug,
to_agent=slug, to_agent=target_slug,
subject=f"[{change.change_type.upper()}] {change.title}", subject=f"[{change.change_type.upper()}] {change.title}",
body=( body=(
f"**Interface change published by `{change.repo.slug}`**\n\n" f"**Interface change published by `{change.repo.slug}`**\n\n"
@ -174,12 +194,9 @@ async def resolve_interface_change(
return InterfaceChangeRead.from_orm_with_slug(change) return InterfaceChangeRead.from_orm_with_slug(change)
async def _repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: async def _repo_by_slug(slug: str, session: AsyncSession):
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) resolution = await resolve_repository_slug(session, slug)
repo = result.scalar_one_or_none() return resolution.repo
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange: async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange:

View file

@ -1,7 +1,153 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.agent_message import AgentMessage from api.models.agent_message import AgentMessage
from hub_core.routers.messages import create_messages_router from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply
from api.services.repository_aliases import (
canonicalize_repository_slug,
resolve_repository_slug,
)
from hub_core.message_identity import resolve_message_reference
from hub_core.models.message_identity_alias import MessageIdentityAlias
router = APIRouter(prefix="/messages", tags=["messages"])
async def _get_message(reference: str, session: AsyncSession) -> AgentMessage:
message_id = await resolve_message_reference(
session, reference, alias_model=MessageIdentityAlias
)
if message_id is None:
raise HTTPException(
status_code=404, detail=f"Message reference {reference!r} not found"
)
message = await session.get(AgentMessage, message_id)
if message is None:
raise HTTPException(
status_code=404, detail=f"Message reference {reference!r} not found"
)
return message
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def send_message(
body: MessageCreate,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
if body.thread_id and await session.get(AgentMessage, body.thread_id) is None:
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
payload = body.model_dump()
payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent)
payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent)
message = AgentMessage(**payload)
session.add(message)
await session.commit()
await session.refresh(message)
return message
@router.get("/", response_model=list[MessageRead])
async def list_messages(
to_agent: str | None = None,
from_agent: str | None = None,
unread_only: bool = False,
limit: int = 50,
session: AsyncSession = Depends(get_session),
) -> list[AgentMessage]:
query = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
if to_agent:
resolution = await resolve_repository_slug(session, to_agent, required=False)
values = resolution.slug_values if resolution else (to_agent,)
query = query.where(
or_(AgentMessage.to_agent.in_(values), AgentMessage.to_agent == "broadcast")
)
if from_agent:
resolution = await resolve_repository_slug(session, from_agent, required=False)
values = resolution.slug_values if resolution else (from_agent,)
query = query.where(AgentMessage.from_agent.in_(values))
if unread_only:
query = query.where(AgentMessage.read_at.is_(None))
result = await session.execute(
query.order_by(AgentMessage.created_at.desc()).limit(limit)
)
return list(result.scalars().all())
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
async def get_thread(
thread_id: str,
session: AsyncSession = Depends(get_session),
) -> list[AgentMessage]:
resolved = await resolve_message_reference(
session, thread_id, alias_model=MessageIdentityAlias
)
if resolved is None:
raise HTTPException(
status_code=404, detail=f"Message reference {thread_id!r} not found"
)
result = await session.execute(
select(AgentMessage)
.where(or_(AgentMessage.id == resolved, AgentMessage.thread_id == resolved))
.order_by(AgentMessage.created_at)
)
return list(result.scalars().all())
@router.patch("/{message_id}/read", response_model=MessageRead)
async def mark_read(
message_id: str,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
message = await _get_message(message_id, session)
if message.read_at is None:
message.read_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(message)
return message
@router.patch("/{message_id}/archive", response_model=MessageRead)
async def archive_message(
message_id: str,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
message = await _get_message(message_id, session)
message.archived_at = datetime.now(timezone.utc)
if message.read_at is None:
message.read_at = message.archived_at
await session.commit()
await session.refresh(message)
return message
@router.post(
"/{message_id}/reply",
response_model=MessageRead,
status_code=status.HTTP_201_CREATED,
)
async def reply_to_message(
message_id: str,
body: MessageReply,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
original = await _get_message(message_id, session)
if original.read_at is None:
original.read_at = datetime.now(timezone.utc)
reply = AgentMessage(
from_agent=await canonicalize_repository_slug(session, body.from_agent),
to_agent=await canonicalize_repository_slug(session, original.from_agent),
subject=f"Re: {original.subject}",
body=body.body,
thread_id=original.thread_id or original.id,
)
session.add(reply)
await session.commit()
await session.refresh(reply)
return reply
router = create_messages_router(get_session, message_model=AgentMessage)
__all__ = ["router"] __all__ = ["router"]

View file

@ -8,16 +8,14 @@ from api.database import get_session
from api.models.managed_repo import ManagedRepo from api.models.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal, RepoGoalStatus from api.models.repo_goal import RepoGoal, RepoGoalStatus
from api.schemas.repo_goal import RepoGoalCreate, RepoGoalRead, RepoGoalUpdate from api.schemas.repo_goal import RepoGoalCreate, RepoGoalRead, RepoGoalUpdate
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/repo-goals", tags=["repo-goals"]) router = APIRouter(prefix="/repo-goals", tags=["repo-goals"])
async def _resolve_repo(repo_slug: str, session: AsyncSession) -> ManagedRepo: async def _resolve_repo(repo_slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug)) resolution = await resolve_repository_slug(session, repo_slug)
repo = result.scalar_one_or_none() return resolution.repo
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_slug}' not found")
return repo
@router.get("/", response_model=list[RepoGoalRead]) @router.get("/", response_model=list[RepoGoalRead])

View file

@ -54,6 +54,13 @@ from api.services.sbom_nexus import SBOMNexusError
from api.services.sbom_nexus import get_json as get_sbom_nexus_json from api.services.sbom_nexus import get_json as get_sbom_nexus_json
from api.services.sbom_nexus import reads_from_nexus from api.services.sbom_nexus import reads_from_nexus
from api.services.repository_identity import stage_initial_repository_identity from api.services.repository_identity import stage_initial_repository_identity
from api.services.repository_aliases import (
RepositorySlugResolution,
affected_slug_predicate,
repository_resolution_for_id,
resolve_repository_slug,
stale_external_references,
)
from hub_core.routers.repos import create_repos_router from hub_core.routers.repos import create_repos_router
router = APIRouter(prefix="/repos", tags=["repos"]) router = APIRouter(prefix="/repos", tags=["repos"])
@ -140,14 +147,14 @@ async def list_repos(
if business_stake: if business_stake:
q = q.where(ManagedRepo.business_stake.contains([business_stake])) q = q.where(ManagedRepo.business_stake.contains([business_stake]))
result = await session.execute(q) result = await session.execute(q)
return await _project_repo_reads(list(result.scalars().all())) return await _project_repo_reads(session, list(result.scalars().all()))
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
async def register_repo( async def register_repo(
body: RepoCreate, body: RepoCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> ManagedRepo: ) -> RepoRead:
domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug)) domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
domain_obj = domain_result.scalar_one_or_none() domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None: if domain_obj is None:
@ -197,7 +204,7 @@ async def register_repo(
) from exc ) from exc
await session.refresh(repo) await session.refresh(repo)
await _publish_repo_registered(repo, body, domain_obj) await _publish_repo_registered(repo, body, domain_obj)
return repo return (await _project_repo_reads(session, [repo]))[0]
@router.post("/onboard", response_model=RepoOnboardResult) @router.post("/onboard", response_model=RepoOnboardResult)
@ -483,7 +490,8 @@ async def get_repo_doi(
Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache. Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache.
""" """
repo = await _get_repo_by_slug(slug, session) resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
sbom_projections = await _sbom_projection_map() sbom_projections = await _sbom_projection_map()
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id)) domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
domain_obj = domain_result.scalar_one_or_none() domain_obj = domain_result.scalar_one_or_none()
@ -517,7 +525,7 @@ async def get_repo_doi(
if not force_refresh and cached and cached.fingerprint == fp and cached.criteria: if not force_refresh and cached and cached.fingerprint == fp and cached.criteria:
return DoIReport( return DoIReport(
repo_slug=slug, repo_slug=repo.slug,
tier=cached.tier, tier=cached.tier,
core_pass=cached.core_pass, core_pass=cached.core_pass,
standard_pass=cached.standard_pass, standard_pass=cached.standard_pass,
@ -556,11 +564,11 @@ async def get_repo_doi(
async def get_repo_by_id( async def get_repo_by_id(
repo_id: uuid.UUID, repo_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> ManagedRepo: ) -> RepoRead:
repo = await session.get(ManagedRepo, repo_id) repo = await session.get(ManagedRepo, repo_id)
if repo is None: if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found") raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found")
return repo return (await _project_repo_reads(session, [repo]))[0]
@router.get("/scope-health", response_model=list[RepoScopeHealth]) @router.get("/scope-health", response_model=list[RepoScopeHealth])
@ -615,9 +623,10 @@ async def update_repo_with_classification(
slug: str, slug: str,
body: RepoUpdate, body: RepoUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> ManagedRepo: ) -> RepoRead:
"""Patch repo metadata including classification spine fields.""" """Patch repo metadata including classification spine fields."""
repo = await _get_repo_by_slug(slug, session) resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
payload = body.model_dump(exclude_unset=True) payload = body.model_dump(exclude_unset=True)
requested_domain_slug = payload.pop("domain_slug", None) requested_domain_slug = payload.pop("domain_slug", None)
if requested_domain_slug is not None: if requested_domain_slug is not None:
@ -658,7 +667,7 @@ async def update_repo_with_classification(
setattr(repo, field, value) setattr(repo, field, value)
await session.commit() await session.commit()
await session.refresh(repo) await session.refresh(repo)
return repo return (await _project_repo_reads(session, [repo], requested=resolution))[0]
@router.get("/{slug}", response_model=RepoRead) @router.get("/{slug}", response_model=RepoRead)
@ -666,28 +675,53 @@ async def get_repo_with_sbom_projection(
slug: str, slug: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> RepoRead: ) -> RepoRead:
repo = await _get_repo_by_slug(slug, session) resolution = await resolve_repository_slug(session, slug)
return (await _project_repo_reads([repo]))[0] return (
await _project_repo_reads(
session,
[resolution.repo],
requested=resolution,
include_stale_external=True,
)
)[0]
router.include_router( router.include_router(
_core_repo_router( _core_repo_router(
include_collection_routes=False, include_collection_routes=False,
include_lookup_routes=False, include_lookup_routes=False,
include_slug_routes=False,
) )
) )
@router.post("/{slug}/paths", response_model=RepoRead)
async def register_repo_path(
slug: str,
body: RepoPathRegister,
session: AsyncSession = Depends(get_session),
) -> RepoRead:
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
host_paths = dict(repo.host_paths or {})
host_paths[body.host] = body.path
repo.host_paths = host_paths
await session.commit()
await session.refresh(repo)
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
@router.patch("/{slug}/archive", response_model=RepoRead) @router.patch("/{slug}/archive", response_model=RepoRead)
async def archive_repo( async def archive_repo(
slug: str, slug: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> ManagedRepo: ) -> RepoRead:
repo = await _get_repo_by_slug(slug, session) resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
repo.status = "archived" repo.status = "archived"
await session.commit() await session.commit()
await session.refresh(repo) await session.refresh(repo)
return repo return (await _project_repo_reads(session, [repo], requested=resolution))[0]
@router.get("/{slug}/dispatch", response_model=RepoDispatch) @router.get("/{slug}/dispatch", response_model=RepoDispatch)
@ -701,7 +735,8 @@ async def get_repo_dispatch(
call it at session start to discover what work is pending without needing to call it at session start to discover what work is pending without needing to
read state-hub summary or scan workplan files manually. read state-hub summary or scan workplan files manually.
""" """
repo = await _get_repo_by_slug(slug, session) resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
# Active goal # Active goal
goal_result = await session.execute( goal_result = await session.execute(
@ -765,7 +800,9 @@ async def get_repo_dispatch(
ic_result = await session.execute( ic_result = await session.execute(
select(InterfaceChange).where( select(InterfaceChange).where(
InterfaceChange.status == "published", InterfaceChange.status == "published",
InterfaceChange.affected_repo_slugs.contains([slug]), affected_slug_predicate(
InterfaceChange.affected_repo_slugs, resolution.slug_values
),
).order_by(InterfaceChange.published_at.desc()) ).order_by(InterfaceChange.published_at.desc())
) )
pending_changes = [ pending_changes = [
@ -794,7 +831,12 @@ async def get_repo_dispatch(
) )
return RepoDispatch( return RepoDispatch(
repo_slug=slug, repo_slug=resolution.canonical_slug,
requested_slug=resolution.requested_slug,
canonical_slug=resolution.canonical_slug,
slug_status=resolution.slug_status,
aliases=list(resolution.aliases),
stale_external_references=await stale_external_references(session, resolution),
active_goal=active_goal, active_goal=active_goal,
active_workplans=dispatch_workstreams, active_workplans=dispatch_workstreams,
human_interventions=all_interventions, human_interventions=all_interventions,
@ -819,7 +861,8 @@ async def sync_repo_consistency(
Returns the raw JSON output from consistency_check.py. Returns the raw JSON output from consistency_check.py.
Query param ?fix=false to run check-only without writing. Query param ?fix=false to run check-only without writing.
""" """
repo = await _get_repo_by_slug(slug, session) resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
hostname = socket.gethostname() hostname = socket.gethostname()
host_paths = repo.host_paths or {} host_paths = repo.host_paths or {}
@ -828,13 +871,13 @@ async def sync_repo_consistency(
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail=( detail=(
f"No accessible path for repo '{slug}' on host '{hostname}'. " f"No accessible path for repo '{repo.slug}' on host '{hostname}'. "
f"Register with: POST /repos/{slug}/paths/" f"Register with: POST /repos/{repo.slug}/paths/"
), ),
) )
script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py" script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
cmd = [sys.executable, str(script), "--repo", slug, "--json", cmd = [sys.executable, str(script), "--repo", repo.slug, "--json",
"--api-base", settings.api_base] "--api-base", settings.api_base]
if fix: if fix:
cmd.append("--fix") cmd.append("--fix")
@ -853,11 +896,8 @@ async def sync_repo_consistency(
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) resolution = await resolve_repository_slug(session, slug)
repo = result.scalar_one_or_none() return resolution.repo
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict: def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict:
@ -899,11 +939,35 @@ def _projected_last_sbom_at(
return str(repo.last_sbom_at) if repo.last_sbom_at else None return str(repo.last_sbom_at) if repo.last_sbom_at else None
async def _project_repo_reads(repositories: list[ManagedRepo]) -> list[RepoRead]: async def _project_repo_reads(
session: AsyncSession,
repositories: list[ManagedRepo],
*,
requested: RepositorySlugResolution | None = None,
include_stale_external: bool = False,
) -> list[RepoRead]:
projections = await _sbom_projection_map() projections = await _sbom_projection_map()
result: list[RepoRead] = [] result: list[RepoRead] = []
for repository in repositories: for repository in repositories:
resolution = (
requested
if requested is not None and requested.repo.id == repository.id
else await repository_resolution_for_id(session, repository)
)
read = RepoRead.model_validate(repository) read = RepoRead.model_validate(repository)
read = read.model_copy(
update={
"requested_slug": resolution.requested_slug,
"canonical_slug": resolution.canonical_slug,
"slug_status": resolution.slug_status,
"aliases": list(resolution.aliases),
"stale_external_references": (
await stale_external_references(session, resolution)
if include_stale_external
else []
),
}
)
if repository.slug in projections: if repository.slug in projections:
read = read.model_copy( read = read.model_copy(
update={ update={

View file

@ -27,6 +27,7 @@ from api.services.sbom_nexus import (
reads_from_nexus, reads_from_nexus,
writes_to_nexus, writes_to_nexus,
) )
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/sbom", tags=["sbom"]) router = APIRouter(prefix="/sbom", tags=["sbom"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -68,9 +69,12 @@ async def ingest_sbom(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> dict: ) -> dict:
"""Create a new SBOM snapshot for a repo. Previous snapshots are retained.""" """Create a new SBOM snapshot for a repo. Previous snapshots are retained."""
repo = await _get_repo_by_slug(body.repo_slug, session) resolution = await resolve_repository_slug(session, body.repo_slug)
repo = resolution.repo
if writes_to_nexus(): if writes_to_nexus():
payload = await _nexus_post("/sbom/ingest/", body=body.model_dump(mode="json")) nexus_body = body.model_dump(mode="json")
nexus_body["repo_slug"] = resolution.canonical_slug
payload = await _nexus_post("/sbom/ingest/", body=nexus_body)
try: try:
snapshot_at = datetime.fromisoformat( snapshot_at = datetime.fromisoformat(
payload["snapshot_at"].replace("Z", "+00:00") payload["snapshot_at"].replace("Z", "+00:00")
@ -126,7 +130,7 @@ async def ingest_sbom(
await session.commit() await session.commit()
await _meter_compat(session, request, "POST", "/sbom/ingest/") await _meter_compat(session, request, "POST", "/sbom/ingest/")
return { return {
"repo_slug": body.repo_slug, "repo_slug": resolution.canonical_slug,
"snapshot_id": str(snap.id), "snapshot_id": str(snap.id),
"ingested": len(body.entries), "ingested": len(body.entries),
"snapshot_at": now.isoformat(), "snapshot_at": now.isoformat(),
@ -142,6 +146,10 @@ async def list_snapshots(
"""List SBOM snapshots, newest first. Optionally filter by repo.""" """List SBOM snapshots, newest first. Optionally filter by repo."""
await _meter_compat(session, request, "GET", "/sbom/snapshots/") await _meter_compat(session, request, "GET", "/sbom/snapshots/")
if reads_from_nexus(): if reads_from_nexus():
if repo_slug:
repo_slug = (
await resolve_repository_slug(session, repo_slug)
).canonical_slug
payload = await _nexus_get( payload = await _nexus_get(
"/sbom/snapshots/", "/sbom/snapshots/",
params={"repo_slug": repo_slug} if repo_slug else None, params={"repo_slug": repo_slug} if repo_slug else None,
@ -207,6 +215,10 @@ async def list_sbom_entries(
"""Return entries from the latest snapshot per repo (default) or filter by repo.""" """Return entries from the latest snapshot per repo (default) or filter by repo."""
await _meter_compat(session, request, "GET", "/sbom/") await _meter_compat(session, request, "GET", "/sbom/")
if reads_from_nexus(): if reads_from_nexus():
if repo_slug:
repo_slug = (
await resolve_repository_slug(session, repo_slug)
).canonical_slug
params = { params = {
key: value key: value
for key, value in { for key, value in {
@ -299,10 +311,11 @@ async def get_repo_sbom(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SBOMRepoView: ) -> SBOMRepoView:
"""Return the latest snapshot entries for a specific repo.""" """Return the latest snapshot entries for a specific repo."""
repo = await _get_repo_by_slug(repo_slug, session) resolution = await resolve_repository_slug(session, repo_slug)
repo = resolution.repo
await _meter_compat(session, request, "GET", "/sbom/{repo_slug}") await _meter_compat(session, request, "GET", "/sbom/{repo_slug}")
if reads_from_nexus(): if reads_from_nexus():
payload = await _nexus_get(f"/sbom/{repo_slug}") payload = await _nexus_get(f"/sbom/{resolution.canonical_slug}")
payload["entries"] = [ payload["entries"] = [
_translate_entry(entry, repo.id) for entry in payload.get("entries", []) _translate_entry(entry, repo.id) for entry in payload.get("entries", [])
] ]
@ -322,7 +335,7 @@ async def get_repo_sbom(
) )
entries = list(rows.scalars().all()) entries = list(rows.scalars().all())
return SBOMRepoView( return SBOMRepoView(
repo_slug=repo_slug, repo_slug=resolution.canonical_slug,
last_sbom_at=repo.last_sbom_at, last_sbom_at=repo.last_sbom_at,
entry_count=len(entries), entry_count=len(entries),
entries=[SBOMEntryRead.model_validate(e) for e in entries], entries=[SBOMEntryRead.model_validate(e) for e in entries],
@ -330,11 +343,8 @@ async def get_repo_sbom(
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) resolution = await resolve_repository_slug(session, slug)
repo = result.scalar_one_or_none() return resolution.repo
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
async def _nexus_get(path: str, *, params: dict | None = None): async def _nexus_get(path: str, *, params: dict | None = None):
@ -355,10 +365,11 @@ async def _local_repo_ids(items: list[dict], session: AsyncSession) -> dict[str,
slugs = {item.get("repo_slug") for item in items} slugs = {item.get("repo_slug") for item in items}
if None in slugs: if None in slugs:
raise HTTPException(status_code=502, detail="SBOM Nexus response omitted repo_slug") raise HTTPException(status_code=502, detail="SBOM Nexus response omitted repo_slug")
result = await session.execute( repo_ids: dict[str, uuid.UUID] = {}
select(ManagedRepo.slug, ManagedRepo.id).where(ManagedRepo.slug.in_(slugs)) for slug in slugs:
) resolution = await resolve_repository_slug(session, slug, required=False)
repo_ids = dict(result.all()) if resolution is not None:
repo_ids[slug] = resolution.repo.id
missing = sorted(slugs - repo_ids.keys()) missing = sorted(slugs - repo_ids.keys())
if missing: if missing:
raise HTTPException( raise HTTPException(

View file

@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from api.database import get_session from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.service_catalog import ( from api.models.service_catalog import (
ServiceCatalog, ServiceCatalog,
ServiceCloud, ServiceCloud,
@ -22,6 +21,7 @@ from api.models.service_catalog import (
ServiceThirdParty, ServiceThirdParty,
) )
from api.schemas.service import ServiceCatalogRead, ServiceUpsert from api.schemas.service import ServiceCatalogRead, ServiceUpsert
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/services", tags=["services"]) router = APIRouter(prefix="/services", tags=["services"])
@ -42,6 +42,7 @@ async def list_services(
development_type: str | None = None, development_type: str | None = None,
maturity_level: int | None = None, maturity_level: int | None = None,
status: str | None = None, status: str | None = None,
repo_slug: str | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[ServiceCatalog]: ) -> list[ServiceCatalog]:
q = select(ServiceCatalog).options(*_WITH_EXTENSIONS) q = select(ServiceCatalog).options(*_WITH_EXTENSIONS)
@ -53,6 +54,11 @@ async def list_services(
q = q.where(ServiceCatalog.maturity_level == maturity_level) q = q.where(ServiceCatalog.maturity_level == maturity_level)
if status: if status:
q = q.where(ServiceCatalog.status == status) q = q.where(ServiceCatalog.status == status)
if repo_slug:
resolution = await resolve_repository_slug(session, repo_slug)
q = q.join(ServiceFirstParty).where(
ServiceFirstParty.repo_id == resolution.repo.id
)
q = q.order_by(ServiceCatalog.name.asc()) q = q.order_by(ServiceCatalog.name.asc())
result = await session.execute(q) result = await session.execute(q)
return list(result.scalars().all()) return list(result.scalars().all())
@ -131,12 +137,10 @@ async def _apply_extensions(svc: ServiceCatalog, body: ServiceUpsert, session: A
if body.first_party is not None: if body.first_party is not None:
data = body.first_party.model_dump(exclude={"repo_slug"}) data = body.first_party.model_dump(exclude={"repo_slug"})
if body.first_party.repo_slug and not data.get("repo_id"): if body.first_party.repo_slug and not data.get("repo_id"):
repo = (await session.execute( resolution = await resolve_repository_slug(
select(ManagedRepo).where(ManagedRepo.slug == body.first_party.repo_slug) session, body.first_party.repo_slug
)).scalar_one_or_none() )
if repo is None: data["repo_id"] = resolution.repo.id
raise HTTPException(status_code=404, detail=f"Repo '{body.first_party.repo_slug}' not found")
data["repo_id"] = repo.id
await _upsert_ext(ServiceFirstParty, svc.id, data, session) await _upsert_ext(ServiceFirstParty, svc.id, data, session)

View file

@ -13,6 +13,7 @@ from api.models.task import Task
from api.models.token_event import TokenEvent from api.models.token_event import TokenEvent
from api.models.workplan import Workplan from api.models.workplan import Workplan
from api.services.legacy_compat import meter_legacy_query_param from api.services.legacy_compat import meter_legacy_query_param
from api.services.repository_aliases import resolve_repository_slug
from api.schemas.token_event import ( from api.schemas.token_event import (
RepoTokenSummary, RepoTokenSummary,
TokenAggregateRow, TokenAggregateRow,
@ -288,6 +289,7 @@ async def get_token_summary(
@router.get("/by-repo/", response_model=list[RepoTokenSummary]) @router.get("/by-repo/", response_model=list[RepoTokenSummary])
async def get_tokens_by_repo( async def get_tokens_by_repo(
repo_slug: str | None = None,
measurement_kind: str | None = None, measurement_kind: str | None = None,
source_provider: str | None = None, source_provider: str | None = None,
since: datetime | None = None, since: datetime | None = None,
@ -304,6 +306,12 @@ async def get_tokens_by_repo(
Only events that resolve to a repo are included. Only events that resolve to a repo are included.
""" """
requested_repo_id = None
if repo_slug:
requested_repo_id = (
await resolve_repository_slug(session, repo_slug)
).repo.id
# Fetch all events, workstreams, repos in three queries (avoids N+1) # Fetch all events, workstreams, repos in three queries (avoids N+1)
events_result = await session.execute( events_result = await session.execute(
_filter_query( _filter_query(
@ -341,6 +349,8 @@ async def get_tokens_by_repo(
rid = resolve_repo_id(e) rid = resolve_repo_id(e)
if not rid or rid not in repo_map: if not rid or rid not in repo_map:
continue continue
if requested_repo_id is not None and rid != requested_repo_id:
continue
if rid not in groups: if rid not in groups:
groups[rid] = { groups[rid] = {
"repo_id": rid, "repo_id": rid,

View file

@ -114,6 +114,11 @@ class RepoRead(CoreRepoRead, ClassificationFields):
last_state_synced_at: datetime | None = None last_state_synced_at: datetime | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
requested_slug: str | None = None
canonical_slug: str | None = None
slug_status: Literal["canonical", "alias"] = "canonical"
aliases: list[str] = Field(default_factory=list)
stale_external_references: list[dict[str, Any]] = Field(default_factory=list)
class DispatchTask(BaseModel): class DispatchTask(BaseModel):
@ -158,6 +163,11 @@ class ScopeIssueDetail(BaseModel):
class RepoDispatch(BaseModel): class RepoDispatch(BaseModel):
repo_slug: str repo_slug: str
requested_slug: str
canonical_slug: str
slug_status: Literal["canonical", "alias"]
aliases: list[str] = Field(default_factory=list)
stale_external_references: list[dict[str, Any]] = Field(default_factory=list)
active_goal: dict[str, Any] | None active_goal: dict[str, Any] | None
active_workplans: list[DispatchWorkplan] active_workplans: list[DispatchWorkplan]
human_interventions: list[DispatchTask] human_interventions: list[DispatchTask]

View file

@ -0,0 +1,155 @@
"""Canonical repository identity resolution across current and prior slugs.
The slug registry is the lookup boundary. Historical records deliberately keep
the slug they recorded; callers use ``slug_values`` when they need an identity-
wide read and ``canonical_slug`` when they create a new reference.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from fastapi import HTTPException
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
from api.models.managed_repo import ManagedRepo
from api.models.repository_rename import RepositorySlug
@dataclass(frozen=True)
class RepositorySlugResolution:
repo: ManagedRepo
requested_slug: str
canonical_slug: str
slug_status: str
aliases: tuple[str, ...]
source_operation_id: uuid.UUID | None = None
@property
def slug_values(self) -> tuple[str, ...]:
return (self.canonical_slug, *self.aliases)
async def resolve_repository_slug(
session: AsyncSession,
slug: str,
*,
required: bool = True,
) -> RepositorySlugResolution | None:
"""Resolve a current or protected prior slug to one repository UUID."""
record = (
await session.execute(
select(RepositorySlug).where(RepositorySlug.slug == slug)
)
).scalar_one_or_none()
# Compatibility for databases upgraded before the identity backfill has run.
if record is None:
repo = (
await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
).scalar_one_or_none()
if repo is None:
if required:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return None
return RepositorySlugResolution(
repo=repo,
requested_slug=slug,
canonical_slug=repo.slug,
slug_status="canonical",
aliases=(),
)
repo = await session.get(ManagedRepo, record.repo_id)
if repo is None:
raise HTTPException(
status_code=409,
detail=f"Repository slug registry entry '{slug}' has no repository",
)
records = list(
(
await session.execute(
select(RepositorySlug)
.where(RepositorySlug.repo_id == repo.id)
.order_by(RepositorySlug.kind.desc(), RepositorySlug.slug)
)
).scalars()
)
canonicals = [item for item in records if item.kind == "canonical"]
if len(canonicals) != 1 or canonicals[0].slug != repo.slug:
raise HTTPException(
status_code=409,
detail=f"Repository '{repo.id}' has inconsistent canonical slug state",
)
return RepositorySlugResolution(
repo=repo,
requested_slug=slug,
canonical_slug=repo.slug,
slug_status=record.kind,
aliases=tuple(item.slug for item in records if item.kind == "alias"),
source_operation_id=record.source_operation_id,
)
async def canonicalize_repository_slug(session: AsyncSession, value: str) -> str:
"""Canonicalize a value only when it is a registered repository identity."""
resolution = await resolve_repository_slug(session, value, required=False)
return resolution.canonical_slug if resolution is not None else value
async def repository_resolution_for_id(
session: AsyncSession, repo: ManagedRepo
) -> RepositorySlugResolution:
return await resolve_repository_slug(session, repo.slug) # type: ignore[return-value]
async def stale_external_references(
session: AsyncSession,
resolution: RepositorySlugResolution,
) -> list[dict[str, object]]:
"""Name external projections that still contain an historical slug.
These are handoffs to the projection owner, never implicit rewrite targets.
"""
if not resolution.aliases:
return []
aliases = list(resolution.aliases)
checks = (
(FabricGraphImport, "source_repo_slug"),
(FabricGraphNode, "source_repo_slug"),
(FabricGraphNode, "repo_slug"),
(FabricGraphEdge, "source_repo_slug"),
)
stale: list[dict[str, object]] = []
for model, field_name in checks:
field = getattr(model, field_name)
rows = (
await session.execute(
select(field, func.count()).where(field.in_(aliases)).group_by(field)
)
).all()
for value, count in rows:
stale.append(
{
"owner": "railiance-fabric",
"surface": model.__tablename__,
"field": field_name,
"value": value,
"count": count,
"status": "stale",
"handoff": "owner update or re-ingest required",
}
)
return stale
def affected_slug_predicate(field: object, slugs: tuple[str, ...]):
"""Match a JSONB slug-list against any name in one repository lineage."""
return or_(*(field.contains([slug]) for slug in slugs))

View file

@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.models.managed_repo import ManagedRepo from api.models.managed_repo import ManagedRepo
from api.models.task import Task, TaskPriority, TaskStatus from api.models.task import Task, TaskPriority, TaskStatus
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
from api.services.repository_aliases import resolve_repository_slug
from api.models.workplan import Workplan from api.models.workplan import Workplan
PLAN_SCHEMA = "repo-manager.identifier-migration-plan.v1" PLAN_SCHEMA = "repo-manager.identifier-migration-plan.v1"
@ -384,11 +385,14 @@ async def repair_absent_prederivation_projection(
outcome = "repaired" outcome = "repaired"
async with session.begin(): async with session.begin():
repo = await session.scalar( resolution = await resolve_repository_slug(session, repo_slug, required=False)
select(ManagedRepo).where(ManagedRepo.slug == repo_slug).with_for_update() if resolution is None:
)
if repo is None:
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}") raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
repo = await session.scalar(
select(ManagedRepo)
.where(ManagedRepo.id == resolution.repo.id)
.with_for_update()
)
if repo.topic_id != workplan_payload["topic_id"]: if repo.topic_id != workplan_payload["topic_id"]:
raise IdentifierMigrationError("repair topic does not match repository projection") raise IdentifierMigrationError("repair topic does not match repository projection")
@ -462,6 +466,7 @@ async def repair_absent_prederivation_projection(
async def _assert_projection_preconditions( async def _assert_projection_preconditions(
session: AsyncSession, session: AsyncSession,
repository_id: uuid.UUID,
repo_slug: str, repo_slug: str,
replacements: list[dict[str, Any]], replacements: list[dict[str, Any]],
*, *,
@ -473,8 +478,7 @@ async def _assert_projection_preconditions(
if mapping["kind"] == "workplan": if mapping["kind"] == "workplan":
source_query = text( source_query = text(
"SELECT workplans.id FROM workplans " "SELECT workplans.id FROM workplans "
"JOIN managed_repos ON managed_repos.id = workplans.repo_id " "WHERE workplans.repo_id = :repository_id AND workplans.id = :record_id "
"WHERE managed_repos.slug = :repo_slug AND workplans.id = :record_id "
"FOR UPDATE" "FOR UPDATE"
) )
target_query = text("SELECT id FROM workplans WHERE id = :record_id") target_query = text("SELECT id FROM workplans WHERE id = :record_id")
@ -482,14 +486,13 @@ async def _assert_projection_preconditions(
source_query = text( source_query = text(
"SELECT tasks.id FROM tasks " "SELECT tasks.id FROM tasks "
"JOIN workplans ON workplans.id = tasks.workplan_id " "JOIN workplans ON workplans.id = tasks.workplan_id "
"JOIN managed_repos ON managed_repos.id = workplans.repo_id " "WHERE workplans.repo_id = :repository_id AND tasks.id = :record_id "
"WHERE managed_repos.slug = :repo_slug AND tasks.id = :record_id "
"FOR UPDATE" "FOR UPDATE"
) )
target_query = text("SELECT id FROM tasks WHERE id = :record_id") target_query = text("SELECT id FROM tasks WHERE id = :record_id")
source = await session.execute( source = await session.execute(
source_query, source_query,
{"repo_slug": repo_slug, "record_id": source_id}, {"repository_id": repository_id, "record_id": source_id},
) )
if source.scalar_one_or_none() is None: if source.scalar_one_or_none() is None:
raise IdentifierMigrationError( raise IdentifierMigrationError(
@ -514,8 +517,11 @@ async def apply_repository_identifier_migration(
raise IdentifierMigrationError("migration requires a fresh database session") raise IdentifierMigrationError("migration requires a fresh database session")
async with session.begin(): async with session.begin():
resolution = await resolve_repository_slug(session, repo_slug, required=False)
if resolution is None:
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
await _assert_projection_preconditions( await _assert_projection_preconditions(
session, repo_slug, replacements, reverse=False session, resolution.repo.id, repo_slug, replacements, reverse=False
) )
aliases = { aliases = {
alias.old_id: alias alias.old_id: alias
@ -596,6 +602,9 @@ async def reverse_repository_identifier_migration(
raise IdentifierMigrationError("migration requires a fresh database session") raise IdentifierMigrationError("migration requires a fresh database session")
async with session.begin(): async with session.begin():
resolution = await resolve_repository_slug(session, repo_slug, required=False)
if resolution is None:
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
aliases = list( aliases = list(
( (
await session.execute( await session.execute(
@ -622,7 +631,7 @@ async def reverse_repository_identifier_migration(
f"durable alias is not applied for {mapping['record_id']}" f"durable alias is not applied for {mapping['record_id']}"
) )
await _assert_projection_preconditions( await _assert_projection_preconditions(
session, repo_slug, replacements, reverse=True session, resolution.repo.id, repo_slug, replacements, reverse=True
) )
for kind in ("task", "workplan"): for kind in ("task", "workplan"):

View file

@ -268,7 +268,7 @@ if (domainBlocks.length === 0) {
</tr></thead> </tr></thead>
<tbody> <tbody>
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}"> ${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
<td class="repo-cell"><code>${r.repo}</code></td> <td class="repo-cell"><a href=${`/repos/${encodeURIComponent(r.repo)}`}><code>${r.repo}</code></a></td>
<td>${r.category}</td> <td>${r.category}</td>
<td class="path-cell" title=${r.capTags}>${r.capTags}</td> <td class="path-cell" title=${r.capTags}>${r.capTags}</td>
<td>${_doiBadge(r._doiTier)}</td> <td>${_doiBadge(r._doiTier)}</td>

View file

@ -21,9 +21,17 @@ if (raw.error) {
const name = raw.name || raw.slug || repoSlug; const name = raw.name || raw.slug || repoSlug;
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Repo · <em>${name}</em></h1>`); display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Repo · <em>${name}</em></h1>`);
display(html`<p style="margin-top:0"><a href="/repos">← Repos</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`); display(html`<p style="margin-top:0"><a href="/repos">← Repos</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`);
if (raw.slug_status === "alias") {
display(html`<div style="border:1px solid #f59e0b;background:#fffbeb;padding:0.75rem 1rem;margin:0.75rem 0;max-width:640px">
<strong>Repository alias:</strong> <code>${raw.requested_slug}</code> now resolves to
<a href=${`/repos/${encodeURIComponent(raw.canonical_slug)}`}><code>${raw.canonical_slug}</code></a>.
Historical records keep the former value.
</div>`);
}
const FIELD_ORDER = [ const FIELD_ORDER = [
"id","slug","name","domain_slug","status","description", "id","slug","requested_slug","canonical_slug","slug_status","aliases",
"name","domain_slug","status","description",
"local_path","remote_url","git_fingerprint", "local_path","remote_url","git_fingerprint",
"sbom_source","last_sbom_at","last_state_synced_at", "sbom_source","last_sbom_at","last_state_synced_at",
"created_at","updated_at", "created_at","updated_at",

View file

@ -1200,6 +1200,9 @@ def check_repo(
) )
return report return report
# Repository aliases are accepted at the API boundary, but every new
# binding, inbox write, and generated file must use the current identity.
repo_slug = str(repo.get("canonical_slug") or repo.get("slug") or repo_slug)
repo_id: str = repo["id"] repo_id: str = repo["id"]
repo_path: str = resolve_repo_path(repo, repo_path_override) repo_path: str = resolve_repo_path(repo, repo_path_override)
report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path) report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path)

View file

@ -0,0 +1,292 @@
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from api.models.agent_message import AgentMessage
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode
from api.models.interface_change import InterfaceChange
from api.models.managed_repo import ManagedRepo
from api.models.repository_rename import RepositorySlug
from tests.conftest import (
create_test_domain,
create_test_repo,
create_test_workplan,
)
async def _rename_in_state_hub(test_engine, repo_id: str) -> None:
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
repo = await session.get(ManagedRepo, repo_id)
old = (
await session.execute(
select(RepositorySlug).where(RepositorySlug.slug == "flex-auth")
)
).scalar_one()
old.kind = "alias"
old.protected = True
repo.slug = "access-engine"
repo.name = "Access Engine"
session.add(
RepositorySlug(
repo_id=repo.id,
slug="access-engine",
kind="canonical",
protected=True,
)
)
await session.commit()
@pytest.mark.asyncio
async def test_alias_lookup_dispatch_bindings_and_external_staleness(
client, test_engine
):
domain = await create_test_domain(client)
repo = await create_test_repo(client, domain_slug=domain["slug"], slug="flex-auth")
workplan = await create_test_workplan(
client,
repo_id=repo["id"],
slug="FLEX-WP-0001",
title="Security stack migration",
)
task = await client.post(
"/tasks/",
json={
"workplan_id": workplan["id"],
"title": "Preserve identity",
"status": "todo",
"priority": "high",
},
)
assert task.status_code == 201, task.text
await _rename_in_state_hub(test_engine, repo["id"])
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
change = InterfaceChange(
repo_id=repo["id"],
interface_type="rest_api",
change_type="breaking",
title="Historical consumer notice",
description="Recorded before cutover",
affected_paths=["/authorize"],
affected_repo_slugs=["flex-auth"],
status="published",
published_at=datetime.now(timezone.utc),
author="pytest",
)
import_run = FabricGraphImport(
source_repo_slug="railiance-fabric",
content_hash="a" * 64,
graph_json={},
validation_status="valid",
is_latest=True,
)
session.add_all([change, import_run])
await session.flush()
session.add(
FabricGraphNode(
import_id=import_run.id,
source_repo_slug="railiance-fabric",
graph_id="repo:flex-auth",
kind="repository",
name="Flex Auth",
repo_slug="flex-auth",
domain_slug="infotech",
lifecycle="active",
)
)
await session.commit()
old_lookup = await client.get("/repos/flex-auth")
new_lookup = await client.get("/repos/access-engine")
assert old_lookup.status_code == new_lookup.status_code == 200
assert old_lookup.json()["id"] == new_lookup.json()["id"] == repo["id"]
assert old_lookup.json()["requested_slug"] == "flex-auth"
assert old_lookup.json()["canonical_slug"] == "access-engine"
assert old_lookup.json()["slug_status"] == "alias"
assert old_lookup.json()["aliases"] == ["flex-auth"]
assert new_lookup.json()["slug_status"] == "canonical"
assert old_lookup.json()["stale_external_references"] == [
{
"owner": "railiance-fabric",
"surface": "fabric_graph_nodes",
"field": "repo_slug",
"value": "flex-auth",
"count": 1,
"status": "stale",
"handoff": "owner update or re-ingest required",
}
]
dispatch = await client.get("/repos/access-engine/dispatch")
assert dispatch.status_code == 200, dispatch.text
assert dispatch.json()["repo_slug"] == "access-engine"
assert dispatch.json()["pending_interface_changes"][0]["title"] == change.title
bound = await client.get(f"/workplans/{workplan['id']}")
assert bound.status_code == 200
assert bound.json()["id"] == workplan["id"]
assert bound.json()["repo_id"] == repo["id"]
bound_task = await client.get(f"/tasks/{task.json()['id']}")
assert bound_task.status_code == 200
assert bound_task.json()["id"] == task.json()["id"]
@pytest.mark.asyncio
async def test_message_history_is_immutable_and_old_slug_write_replays_once(
client, test_engine
):
domain = await create_test_domain(client)
repo = await create_test_repo(client, domain_slug=domain["slug"], slug="flex-auth")
await _rename_in_state_hub(test_engine, repo["id"])
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
historical = AgentMessage(
from_agent="security-review",
to_agent="flex-auth",
subject="Before cutover",
body="Historical recipient must not be rewritten",
)
session.add(historical)
await session.commit()
historical_id = historical.id
history = await client.get("/messages/", params={"to_agent": "access-engine"})
assert history.status_code == 200
assert [item["id"] for item in history.json()] == [str(historical_id)]
assert history.json()[0]["to_agent"] == "flex-auth"
payload = {
"from_agent": "flex-auth",
"to_agent": "flex-auth",
"subject": "Queued during cutover",
"body": "Replay exactly once",
}
headers = {"Idempotency-Key": "repo-rename-old-slug-message"}
first = await client.post("/messages/", json=payload, headers=headers)
replay = await client.post("/messages/", json=payload, headers=headers)
assert first.status_code == replay.status_code == 201
assert first.json()["id"] == replay.json()["id"]
assert first.json()["from_agent"] == "access-engine"
assert first.json()["to_agent"] == "access-engine"
async with factory() as session:
assert await session.scalar(
select(func.count())
.select_from(AgentMessage)
.where(AgentMessage.subject == "Queued during cutover")
) == 1
unchanged = await session.get(AgentMessage, historical_id)
assert unchanged.to_agent == "flex-auth"
@pytest.mark.asyncio
async def test_alias_aware_interface_catalog_sbom_and_telemetry_reads(
client, test_engine
):
domain = await create_test_domain(client)
repo = await create_test_repo(client, domain_slug=domain["slug"], slug="flex-auth")
await _rename_in_state_hub(test_engine, repo["id"])
interface = await client.post(
"/interface-changes/",
json={
"repo_slug": "flex-auth",
"interface_type": "rest_api",
"change_type": "additive",
"title": "Canonical write",
"description": "New references use the current slug",
"affected_repo_slugs": ["flex-auth"],
},
)
assert interface.status_code == 201, interface.text
assert interface.json()["repo_slug"] == "access-engine"
assert interface.json()["affected_repo_slugs"] == ["access-engine"]
affected = await client.get(
"/interface-changes/", params={"affected_repo": "flex-auth"}
)
assert [item["id"] for item in affected.json()] == [interface.json()["id"]]
goal = await client.post(
"/repo-goals/",
json={
"repo_id": repo["id"],
"title": "Preserve authorization continuity",
"description": "Keep Net Kingdom consumers online",
},
)
assert goal.status_code == 201, goal.text
goals = await client.get("/repo-goals/", params={"repo_slug": "flex-auth"})
assert [item["id"] for item in goals.json()] == [goal.json()["id"]]
assert goals.json()[0]["repo_slug"] == "access-engine"
capability = await client.post(
"/capability-catalog/",
json={
"domain": "infotech",
"repo_slug": "flex-auth",
"capability_type": "authorization",
"title": "Policy decisions",
"description": "Net Kingdom authorization",
"keywords": ["authorize"],
},
)
assert capability.status_code == 201, capability.text
catalog = await client.get(
"/capability-catalog/", params={"repo_slug": "access-engine"}
)
assert [item["id"] for item in catalog.json()] == [capability.json()["id"]]
assert catalog.json()[0]["repo_slug"] == "access-engine"
service = await client.post(
"/services/catalog",
json={
"slug": "access-engine-api",
"name": "Access Engine API",
"hosting_type": "self_hosted",
"development_type": "first_party",
"first_party": {"repo_slug": "flex-auth", "owning_domain": "infotech"},
"self_hosted": {},
},
)
assert service.status_code == 201, service.text
services = await client.get("/services/catalog", params={"repo_slug": "flex-auth"})
assert [item["id"] for item in services.json()] == [service.json()["id"]]
ingested = await client.post(
"/sbom/ingest/",
json={
"repo_slug": "flex-auth",
"entries": [
{
"package_name": "opa",
"package_version": "1.0",
"ecosystem": "go",
}
],
},
)
assert ingested.status_code == 200, ingested.text
assert ingested.json()["repo_slug"] == "access-engine"
sbom = await client.get("/sbom/flex-auth")
assert sbom.status_code == 200
assert sbom.json()["repo_slug"] == "access-engine"
event = await client.post(
"/token-events/",
json={"repo_id": repo["id"], "tokens_in": 12, "tokens_out": 3},
)
assert event.status_code == 201, event.text
summary = await client.get(
"/token-events/by-repo/", params={"repo_slug": "flex-auth"}
)
assert summary.status_code == 200
assert len(summary.json()) == 1
assert summary.json()[0]["repo_slug"] == "access-engine"
assert summary.json()[0]["tokens_total"] == 15

View file

@ -12,6 +12,7 @@ from api.models import Base
from api.models.domain import Domain from api.models.domain import Domain
from api.models.managed_repo import ManagedRepo from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent from api.models.progress_event import ProgressEvent
from api.models.repository_rename import RepositorySlug
from api.models.task import Task from api.models.task import Task
from api.models.topic import Topic from api.models.topic import Topic
from api.models.token_event import TokenEvent from api.models.token_event import TokenEvent
@ -276,6 +277,57 @@ async def test_repository_migration_cascades_and_reverses(test_engine):
assert all(alias.reversed_at is not None for alias in aliases) assert all(alias.reversed_at is not None for alias in aliases)
@pytest.mark.asyncio
async def test_repository_migration_accepts_protected_prior_slug(test_engine):
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
ids = await _seed_projection(factory, "flex-auth")
plan = _sealed_plan("flex-auth", ids["workplan"], ids["task"])
async with factory() as session:
repo = (
await session.execute(
select(ManagedRepo).where(ManagedRepo.slug == "flex-auth")
)
).scalar_one()
repo.slug = "access-engine"
session.add_all(
[
RepositorySlug(
repo_id=repo.id,
slug="flex-auth",
kind="alias",
protected=True,
),
RepositorySlug(
repo_id=repo.id,
slug="access-engine",
kind="canonical",
protected=True,
),
]
)
await session.commit()
async with factory() as session:
result = await apply_repository_identifier_migration(session, plan, "flex-auth")
assert result.direction == "forward"
async with factory() as session:
aliases = list((await session.execute(select(WorkRecordIdentifierAlias))).scalars())
assert len(aliases) == 2
assert {alias.repo_slug for alias in aliases} == {"flex-auth"}
repo = (
await session.execute(
select(ManagedRepo).where(ManagedRepo.slug == "access-engine")
)
).scalar_one()
assert repo.id is not None
async with factory() as session:
result = await reverse_repository_identifier_migration(session, plan, "flex-auth")
assert result.direction == "reverse"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_repository_migration_is_atomic_when_a_source_is_missing(test_engine): async def test_repository_migration_is_atomic_when_a_source_is_missing(test_engine):
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)

View file

@ -298,7 +298,7 @@ guarding, and full migration upgrade/downgrade. The full Python suite passes
```task ```task
id: STATE-WP-0085-T04 id: STATE-WP-0085-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "dcf7102e-ed7a-503e-bf78-db2149f72453" state_hub_task_id: "dcf7102e-ed7a-503e-bf78-db2149f72453"
``` ```
@ -331,6 +331,17 @@ Acceptance:
- new bindings use the new slug while workplan and task UUIDs remain unchanged; - new bindings use the new slug while workplan and task UUIDs remain unchanged;
- external stale references are named rather than silently rewritten. - external stale references are named rather than silently rewritten.
Implemented one protected-slug resolver and applied it across repository reads,
dispatch, consistency/path binding, inbox routing, interface changes,
work-record identifier migration, goals, capability and service catalogs, SBOM,
token summaries, Fabric projection reads, and dashboard links. Repository
responses expose requested/canonical slug, canonical-or-alias status, aliases,
and named stale external projections. Historical message and interface values
remain unchanged; new writes are canonicalized. An idempotent old-slug message
replay is proven to persist once, while workplan/task UUIDs and repository FKs
remain stable. Verification: 775 Python tests and the 70-page dashboard build
pass (the build retains one pre-existing `/docs/intakes` broken-link warning).
## Add the State Hub CLI and HelixForge orchestration contract ## Add the State Hub CLI and HelixForge orchestration contract
```task ```task