generated from coulomb/repo-seed
feat(state-hub): Interface Change Registry (CUST-WP-0033 T01-T06)
Adds first-class tracking for API and interface mutations across the
agent ecosystem. Breaking changes are documented, affected repos are
notified via inbox, and agents discover pending changes at session
start via the dispatch endpoint.
- Migration q4l5m6n7o8p9: interface_changes table
- Model/schema: InterfaceChange with draft→published→resolved lifecycle
- Router: POST/GET/PATCH /interface-changes/, /publish, /resolve actions
(auto-notify affected repo agents on publish; progress event on origin)
- Dispatch: GET /repos/{slug}/dispatch now returns pending_interface_changes
- MCP tools: register_interface_change, list_interface_changes,
publish_interface_change, resolve_interface_change
- Dashboard: /interface-changes page with type badges, planned calendar,
published cards, and draft table
- EP-CUST-ICR-001 registered: webhook subscriptions (deliberately deferred)
First record: trailing-slash normalisation (2026-04-26), published,
affecting repo-registry — visible in repo-registry dispatch immediately.
223 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ from api.database import engine
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc
|
||||
from api.routers import token_events
|
||||
from api.routers import interface_changes
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -51,6 +52,7 @@ app.include_router(messages.router)
|
||||
app.include_router(capability_requests.router)
|
||||
app.include_router(tpsc.router)
|
||||
app.include_router(token_events.router)
|
||||
app.include_router(interface_changes.router)
|
||||
app.include_router(state.router)
|
||||
app.include_router(policy.router)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from api.models.capability_request import CapabilityRequest
|
||||
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
||||
from api.models.doi_cache import DOICache
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.interface_change import InterfaceChange
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -44,4 +45,5 @@ __all__ = [
|
||||
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
||||
"DOICache",
|
||||
"TokenEvent",
|
||||
"InterfaceChange",
|
||||
]
|
||||
|
||||
53
api/models/interface_change.py
Normal file
53
api/models/interface_change.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class InterfaceChange(Base, TimestampMixin):
|
||||
__tablename__ = "interface_changes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
interface_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False
|
||||
) # rest_api | mcp_tool | cli | schema | capability
|
||||
change_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False
|
||||
) # breaking | additive | deprecation | removal
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
affected_paths: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default="[]"
|
||||
)
|
||||
affected_repo_slugs: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default="[]"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="draft", index=True
|
||||
) # draft | published | resolved
|
||||
planned_for: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
author: Mapped[str] = mapped_column(String(100), nullable=False, default="custodian")
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_interface_changes_repo_status", "repo_id", "status"),
|
||||
)
|
||||
192
api/routers/interface_changes.py
Normal file
192
api/routers/interface_changes.py
Normal file
@@ -0,0 +1,192 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.interface_change import (
|
||||
InterfaceChangeCreate,
|
||||
InterfaceChangePatch,
|
||||
InterfaceChangeRead,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/interface-changes", tags=["interface-changes"])
|
||||
|
||||
_VALID_INTERFACE_TYPES = {"rest_api", "mcp_tool", "cli", "schema", "capability"}
|
||||
_VALID_CHANGE_TYPES = {"breaking", "additive", "deprecation", "removal"}
|
||||
|
||||
|
||||
@router.post("/", response_model=InterfaceChangeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_interface_change(
|
||||
body: InterfaceChangeCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
if body.interface_type not in _VALID_INTERFACE_TYPES:
|
||||
raise HTTPException(status_code=422, detail=f"interface_type must be one of {sorted(_VALID_INTERFACE_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)}")
|
||||
|
||||
repo = await _repo_by_slug(body.repo_slug, session)
|
||||
change = InterfaceChange(
|
||||
repo_id=repo.id,
|
||||
interface_type=body.interface_type,
|
||||
change_type=body.change_type,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
affected_paths=body.affected_paths,
|
||||
affected_repo_slugs=body.affected_repo_slugs,
|
||||
planned_for=body.planned_for,
|
||||
author=body.author,
|
||||
status="draft",
|
||||
)
|
||||
session.add(change)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[InterfaceChangeRead])
|
||||
async def list_interface_changes(
|
||||
repo_slug: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
change_type: str | None = Query(None),
|
||||
affected_repo: str | None = Query(None, description="Return changes that affect this repo slug"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[InterfaceChangeRead]:
|
||||
q = select(InterfaceChange).order_by(InterfaceChange.created_at.desc())
|
||||
if repo_slug:
|
||||
repo = await _repo_by_slug(repo_slug, session)
|
||||
q = q.where(InterfaceChange.repo_id == repo.id)
|
||||
if status:
|
||||
q = q.where(InterfaceChange.status == status)
|
||||
if change_type:
|
||||
q = q.where(InterfaceChange.change_type == change_type)
|
||||
if affected_repo:
|
||||
q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo]))
|
||||
result = await session.execute(q)
|
||||
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{change_id}", response_model=InterfaceChangeRead)
|
||||
async def get_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.patch("/{change_id}", response_model=InterfaceChangeRead)
|
||||
async def patch_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
body: InterfaceChangePatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
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():
|
||||
setattr(change, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.post("/{change_id}/publish", response_model=InterfaceChangeRead)
|
||||
async def publish_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot publish a change with status '{change.status}'. Must be 'draft'.",
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
change.status = "published"
|
||||
change.published_at = now
|
||||
|
||||
# Send inbox notifications to agents of affected repos
|
||||
affected = change.affected_repo_slugs or []
|
||||
for slug in affected:
|
||||
paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description"
|
||||
if len(change.affected_paths) > 5:
|
||||
paths_summary += f" (+{len(change.affected_paths) - 5} more)"
|
||||
msg = AgentMessage(
|
||||
from_agent=change.repo.slug,
|
||||
to_agent=slug,
|
||||
subject=f"[{change.change_type.upper()}] {change.title}",
|
||||
body=(
|
||||
f"**Interface change published by `{change.repo.slug}`**\n\n"
|
||||
f"- Type: `{change.interface_type}` / `{change.change_type}`\n"
|
||||
f"- Affected paths: {paths_summary}\n\n"
|
||||
f"{change.description}\n\n"
|
||||
f"Change ID: `{change.id}` — resolve with "
|
||||
f"`POST /interface-changes/{change.id}/resolve` once adapted."
|
||||
),
|
||||
)
|
||||
session.add(msg)
|
||||
|
||||
# Progress event on the originating repo
|
||||
session.add(ProgressEvent(
|
||||
event_type="milestone",
|
||||
summary=f"Interface change published: {change.title}",
|
||||
detail={
|
||||
"change_id": str(change.id),
|
||||
"change_type": change.change_type,
|
||||
"interface_type": change.interface_type,
|
||||
"affected_repos": affected,
|
||||
"notifications_sent": len(affected),
|
||||
},
|
||||
author=change.author,
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.post("/{change_id}/resolve", response_model=InterfaceChangeRead)
|
||||
async def resolve_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "published":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot resolve a change with status '{change.status}'. Must be 'published'.",
|
||||
)
|
||||
change.status = "resolved"
|
||||
change.resolved_at = datetime.now(tz=timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
async def _repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
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:
|
||||
result = await session.execute(
|
||||
select(InterfaceChange).where(InterfaceChange.id == change_id)
|
||||
)
|
||||
change = result.scalar_one_or_none()
|
||||
if change is None:
|
||||
raise HTTPException(status_code=404, detail=f"InterfaceChange '{change_id}' not found")
|
||||
return change
|
||||
@@ -16,6 +16,7 @@ from api.database import get_session
|
||||
from api.doi_engine import compute_fingerprint, evaluate as _doi_evaluate
|
||||
from api.models.doi_cache import DOICache
|
||||
from api.models.domain import Domain
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal
|
||||
from api.models.tpsc import TPSCSnapshot
|
||||
@@ -25,6 +26,7 @@ from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||
from api.schemas.managed_repo import (
|
||||
DispatchTask,
|
||||
DispatchWorkstream,
|
||||
PendingInterfaceChange,
|
||||
RepoCreate,
|
||||
RepoDispatch,
|
||||
RepoPathRegister,
|
||||
@@ -468,11 +470,33 @@ async def get_repo_dispatch(
|
||||
)
|
||||
)
|
||||
|
||||
# Published interface changes that affect this repo and are not yet resolved
|
||||
ic_result = await session.execute(
|
||||
select(InterfaceChange).where(
|
||||
InterfaceChange.status == "published",
|
||||
InterfaceChange.affected_repo_slugs.contains([slug]),
|
||||
).order_by(InterfaceChange.published_at.desc())
|
||||
)
|
||||
pending_changes = [
|
||||
PendingInterfaceChange(
|
||||
id=ic.id,
|
||||
title=ic.title,
|
||||
change_type=ic.change_type,
|
||||
interface_type=ic.interface_type,
|
||||
origin_repo_slug=ic.repo.slug,
|
||||
affected_paths=ic.affected_paths or [],
|
||||
planned_for=ic.planned_for,
|
||||
published_at=ic.published_at,
|
||||
)
|
||||
for ic in ic_result.scalars().all()
|
||||
]
|
||||
|
||||
return RepoDispatch(
|
||||
repo_slug=slug,
|
||||
active_goal=active_goal,
|
||||
active_workstreams=dispatch_workstreams,
|
||||
human_interventions=all_interventions,
|
||||
pending_interface_changes=pending_changes,
|
||||
last_state_synced_at=repo.last_state_synced_at,
|
||||
)
|
||||
|
||||
|
||||
66
api/schemas/interface_change.py
Normal file
66
api/schemas/interface_change.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class InterfaceChangeCreate(BaseModel):
|
||||
repo_slug: str
|
||||
interface_type: str # rest_api | mcp_tool | cli | schema | capability
|
||||
change_type: str # breaking | additive | deprecation | removal
|
||||
title: str
|
||||
description: str
|
||||
affected_paths: list[str] = []
|
||||
affected_repo_slugs: list[str] = []
|
||||
planned_for: date | None = None
|
||||
author: str = "custodian"
|
||||
|
||||
|
||||
class InterfaceChangePatch(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
affected_paths: list[str] | None = None
|
||||
affected_repo_slugs: list[str] | None = None
|
||||
planned_for: date | None = None
|
||||
|
||||
|
||||
class InterfaceChangeRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
repo_slug: str
|
||||
interface_type: str
|
||||
change_type: str
|
||||
title: str
|
||||
description: str
|
||||
affected_paths: list[str]
|
||||
affected_repo_slugs: list[str]
|
||||
status: str
|
||||
planned_for: date | None
|
||||
published_at: datetime | None
|
||||
resolved_at: datetime | None
|
||||
author: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_orm_with_slug(cls, obj) -> "InterfaceChangeRead":
|
||||
return cls(
|
||||
id=obj.id,
|
||||
repo_id=obj.repo_id,
|
||||
repo_slug=obj.repo.slug,
|
||||
interface_type=obj.interface_type,
|
||||
change_type=obj.change_type,
|
||||
title=obj.title,
|
||||
description=obj.description,
|
||||
affected_paths=obj.affected_paths or [],
|
||||
affected_repo_slugs=obj.affected_repo_slugs or [],
|
||||
status=obj.status,
|
||||
planned_for=obj.planned_for,
|
||||
published_at=obj.published_at,
|
||||
resolved_at=obj.resolved_at,
|
||||
author=obj.author,
|
||||
created_at=obj.created_at,
|
||||
updated_at=obj.updated_at,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -68,9 +68,21 @@ class DispatchWorkstream(BaseModel):
|
||||
pending_tasks: list[DispatchTask]
|
||||
|
||||
|
||||
class PendingInterfaceChange(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
change_type: str
|
||||
interface_type: str
|
||||
origin_repo_slug: str
|
||||
affected_paths: list[str]
|
||||
planned_for: date | None
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
class RepoDispatch(BaseModel):
|
||||
repo_slug: str
|
||||
active_goal: dict[str, Any] | None
|
||||
active_workstreams: list[DispatchWorkstream]
|
||||
human_interventions: list[DispatchTask]
|
||||
pending_interface_changes: list[PendingInterfaceChange]
|
||||
last_state_synced_at: datetime | None
|
||||
|
||||
Reference in New Issue
Block a user