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:
2026-04-26 15:29:08 +02:00
parent 17f3272a0f
commit 17303d2519
11 changed files with 696 additions and 4 deletions

View File

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

View File

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

View 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"),
)

View 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

View File

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

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

View File

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

View File

@@ -56,9 +56,10 @@ export default {
collapsible: true,
open: false,
pages: [
{ name: "Decisions", path: "/decisions" },
{ name: "Dependencies", path: "/dependencies" },
{ name: "Extends", path: "/extensions" },
{ name: "Decisions", path: "/decisions" },
{ name: "Dependencies", path: "/dependencies" },
{ name: "Extends", path: "/extensions" },
{ name: "Interface Changes", path: "/interface-changes" },
{ name: "Interventions", path: "/interventions" },
{ name: "Tasks", path: "/tasks" },
{ name: "UI Feedback", path: "/ui-feedback" },

View File

@@ -0,0 +1,154 @@
---
title: Interface Changes
---
```js
import {API} from "./components/config.js";
```
```js
const [published, draft, repos] = await Promise.all([
fetch(`${API}/interface-changes/?status=published`).then(r => r.ok ? r.json() : []),
fetch(`${API}/interface-changes/?status=draft`).then(r => r.ok ? r.json() : []),
fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []),
]);
```
```js
const repoMap = Object.fromEntries(repos.map(r => [r.slug, r]));
const CHANGE_TYPE_COLOR = {
breaking: "#ef4444",
removal: "#f97316",
deprecation: "#f59e0b",
additive: "#22c55e",
};
function changeBadge(type) {
const color = CHANGE_TYPE_COLOR[type] ?? "#6b7280";
return htl.html`<span style="
background:${color};color:#fff;font-size:11px;font-weight:700;
padding:2px 7px;border-radius:10px;text-transform:uppercase;letter-spacing:.04em
">${type}</span>`;
}
function interfaceBadge(type) {
return htl.html`<span style="
background:#3b82f6;color:#fff;font-size:10px;font-weight:600;
padding:1px 6px;border-radius:8px
">${type}</span>`;
}
```
# Interface Changes
Track breaking and additive mutations to published interfaces across repos.
Publishing a change sends inbox notifications to all affected agents.
<div style="display:flex;gap:16px;margin-bottom:24px">
<div style="background:#fef2f2;border:1px solid #fca5a5;border-radius:8px;padding:12px 20px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#dc2626">${published.filter(c => c.change_type === "breaking").length}</div>
<div style="font-size:12px;color:#6b7280">Breaking (live)</div>
</div>
<div style="background:#fff7ed;border:1px solid #fdba74;border-radius:8px;padding:12px 20px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#ea580c">${published.filter(c => c.change_type === "deprecation").length}</div>
<div style="font-size:12px;color:#6b7280">Deprecations (live)</div>
</div>
<div style="background:#f0fdf4;border:1px solid #86efac;border-radius:8px;padding:12px 20px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#16a34a">${published.filter(c => c.change_type === "additive").length}</div>
<div style="font-size:12px;color:#6b7280">Additive (live)</div>
</div>
<div style="background:#f9fafb;border:1px solid #d1d5db;border-radius:8px;padding:12px 20px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#374151">${draft.length}</div>
<div style="font-size:12px;color:#6b7280">Drafts</div>
</div>
</div>
## Planned changes
```js
const planned = [...published, ...draft].filter(c => c.planned_for);
planned.sort((a, b) => a.planned_for.localeCompare(b.planned_for));
```
```js
if (planned.length === 0) {
display(htl.html`<p style="color:#6b7280;font-style:italic">No changes with a planned date.</p>`);
} else {
display(htl.html`<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:2px solid #e5e7eb">
<th style="text-align:left;padding:6px 8px">Date</th>
<th style="text-align:left;padding:6px 8px">Change</th>
<th style="text-align:left;padding:6px 8px">Repo</th>
<th style="text-align:left;padding:6px 8px">Status</th>
</tr></thead>
<tbody>
${planned.map(c => htl.html`<tr style="border-bottom:1px solid #f3f4f6">
<td style="padding:6px 8px;white-space:nowrap;color:#374151">${c.planned_for}</td>
<td style="padding:6px 8px">${changeBadge(c.change_type)} ${c.title}</td>
<td style="padding:6px 8px;color:#6b7280">${c.origin_repo_slug ?? c.repo_slug}</td>
<td style="padding:6px 8px">${c.status}</td>
</tr>`)}
</tbody>
</table>`);
}
```
## Published changes
```js
if (published.length === 0) {
display(htl.html`<p style="color:#6b7280;font-style:italic">No published changes.</p>`);
} else {
display(htl.html`<div style="display:flex;flex-direction:column;gap:12px">
${published.map(c => htl.html`
<div style="border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap">
${changeBadge(c.change_type)}
${interfaceBadge(c.interface_type)}
<strong style="font-size:14px">${c.title}</strong>
</div>
<div style="font-size:12px;color:#6b7280;margin-bottom:6px">
Repo: <strong>${c.origin_repo_slug ?? c.repo_slug}</strong>
${c.published_at ? htl.html` · Published ${c.published_at.slice(0,10)}` : ""}
${c.affected_repo_slugs?.length ? htl.html` · Affects: ${c.affected_repo_slugs.join(", ")}` : ""}
</div>
<details style="font-size:13px;color:#374151">
<summary style="cursor:pointer;color:#6b7280">Description</summary>
<pre style="margin-top:6px;white-space:pre-wrap;font-family:inherit">${c.description}</pre>
</details>
${c.affected_paths?.length ? htl.html`
<div style="margin-top:6px;font-size:12px">
<strong>Paths:</strong>
${c.affected_paths.map(p => htl.html`<code style="background:#f3f4f6;padding:1px 5px;border-radius:4px;margin:0 2px">${p}</code>`)}
</div>` : ""}
</div>
`)}
</div>`);
}
```
## Drafts
```js
if (draft.length === 0) {
display(htl.html`<p style="color:#6b7280;font-style:italic">No draft changes.</p>`);
} else {
display(htl.html`<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:2px solid #e5e7eb">
<th style="text-align:left;padding:6px 8px">Title</th>
<th style="text-align:left;padding:6px 8px">Repo</th>
<th style="text-align:left;padding:6px 8px">Type</th>
<th style="text-align:left;padding:6px 8px">Affected</th>
</tr></thead>
<tbody>
${draft.map(c => htl.html`<tr style="border-bottom:1px solid #f3f4f6">
<td style="padding:6px 8px">${c.title}</td>
<td style="padding:6px 8px;color:#6b7280">${c.repo_slug}</td>
<td style="padding:6px 8px">${changeBadge(c.change_type)} ${interfaceBadge(c.interface_type)}</td>
<td style="padding:6px 8px;color:#6b7280">${(c.affected_repo_slugs ?? []).join(", ") || "—"}</td>
</tr>`)}
</tbody>
</table>`);
}
```

View File

@@ -2557,6 +2557,147 @@ def get_token_summary(scope: str, id: str) -> str:
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Interface Change Registry
# ---------------------------------------------------------------------------
@mcp.tool()
def register_interface_change(
repo_slug: str,
interface_type: str,
change_type: str,
title: str,
description: str,
affected_paths: list[str] | None = None,
affected_repo_slugs: list[str] | None = None,
planned_for: str | None = None,
author: str = "custodian",
) -> str:
"""Create a draft InterfaceChange record.
Documents a mutation to a published interface boundary. Records stay in
'draft' status until explicitly published via publish_interface_change().
Args:
repo_slug: Slug of the repo that owns the interface.
interface_type: One of: rest_api, mcp_tool, cli, schema, capability.
change_type: One of: breaking, additive, deprecation, removal.
title: Short summary (e.g. 'Remove trailing slash from param routes').
description: Full before/after description of what changed.
affected_paths: Specific endpoints, tool names, or fields changed.
affected_repo_slugs: Repos known to consume this interface.
planned_for: ISO date string (YYYY-MM-DD) if change is pre-announced.
author: Agent or person creating this record.
"""
payload = {
"repo_slug": repo_slug,
"interface_type": interface_type,
"change_type": change_type,
"title": title,
"description": description,
"affected_paths": affected_paths or [],
"affected_repo_slugs": affected_repo_slugs or [],
"author": author,
}
if planned_for:
payload["planned_for"] = planned_for
result = _post("/interface-changes/", payload)
if isinstance(result, dict) and result.get("id"):
return (
f"Draft created: {result['title']}\n"
f"ID: {result['id']}\n"
f"Repo: {result['repo_slug']} / {result['interface_type']} / {result['change_type']}\n"
f"Affected repos: {', '.join(result['affected_repo_slugs']) or '(none listed)'}\n"
f"Status: draft — call publish_interface_change('{result['id']}') when ready."
)
return f"Error: {result}"
@mcp.tool()
def list_interface_changes(
repo_slug: str | None = None,
status: str | None = None,
change_type: str | None = None,
affected_repo: str | None = None,
) -> str:
"""List interface change records with optional filters.
Args:
repo_slug: Filter by originating repo.
status: Filter by status: draft | published | resolved.
change_type: Filter by change type: breaking | additive | deprecation | removal.
affected_repo: Return changes that affect this repo slug.
"""
params: dict = {}
if repo_slug:
params["repo_slug"] = repo_slug
if status:
params["status"] = status
if change_type:
params["change_type"] = change_type
if affected_repo:
params["affected_repo"] = affected_repo
results = _get("/interface-changes/", params if params else None)
if not isinstance(results, list):
return f"Error: {results}"
if not results:
return "No interface changes found matching the given filters."
lines = [f"Interface Changes ({len(results)} found):", ""]
for r in results:
planned = f" [planned {r['planned_for']}]" if r.get("planned_for") else ""
pub = f" [published {r['published_at'][:10]}]" if r.get("published_at") else ""
affected = ", ".join(r["affected_repo_slugs"]) or "(none)"
lines += [
f"[{r['status'].upper()}] {r['title']}{planned}{pub}",
f" ID: {r['id']}",
f" {r['repo_slug']} / {r['interface_type']} / {r['change_type']}",
f" Affected: {affected}",
"",
]
return "\n".join(lines)
@mcp.tool()
def publish_interface_change(change_id: str) -> str:
"""Publish a draft InterfaceChange, making it live and notifying affected agents.
Transitions status draft → published, sets published_at, sends an inbox
message to each affected_repo_slug agent, and appends a progress event.
Args:
change_id: UUID of the InterfaceChange to publish.
"""
result = _post(f"/interface-changes/{change_id}/publish", {})
if isinstance(result, dict) and result.get("status") == "published":
n = len(result.get("affected_repo_slugs") or [])
return (
f"Published: {result['title']}\n"
f"ID: {result['id']}\n"
f"Notifications sent to {n} repo(s): "
f"{', '.join(result['affected_repo_slugs']) or '(none)'}\n"
f"Resolve with: resolve_interface_change('{result['id']}')"
)
return f"Error: {result}"
@mcp.tool()
def resolve_interface_change(change_id: str) -> str:
"""Mark a published InterfaceChange as resolved.
Call this once all known dependents have adapted. Transitions
status published → resolved and sets resolved_at.
Args:
change_id: UUID of the InterfaceChange to resolve.
"""
result = _post(f"/interface-changes/{change_id}/resolve", {})
if isinstance(result, dict) and result.get("status") == "resolved":
return f"Resolved: {result['title']} (ID: {result['id']})"
return f"Error: {result}"
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,45 @@
"""add interface_changes table
Revision ID: q4l5m6n7o8p9
Revises: p3k4l5m6n7o8
Create Date: 2026-04-26
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision = "q4l5m6n7o8p9"
down_revision = "p3k4l5m6n7o8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"interface_changes",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("repo_id", UUID(as_uuid=True),
sa.ForeignKey("managed_repos.id", ondelete="CASCADE"),
nullable=False),
sa.Column("interface_type", sa.String(40), nullable=False),
sa.Column("change_type", sa.String(40), nullable=False),
sa.Column("title", sa.String(300), nullable=False),
sa.Column("description", sa.Text, nullable=False),
sa.Column("affected_paths", JSONB, nullable=False, server_default="[]"),
sa.Column("affected_repo_slugs", JSONB, nullable=False, server_default="[]"),
sa.Column("status", sa.String(20), nullable=False, server_default="draft"),
sa.Column("planned_for", sa.Date, nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("author", sa.String(100), nullable=False, server_default="custodian"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_interface_changes_repo_id", "interface_changes", ["repo_id"])
op.create_index("ix_interface_changes_status", "interface_changes", ["status"])
op.create_index("ix_interface_changes_repo_status", "interface_changes", ["repo_id", "status"])
def downgrade() -> None:
op.drop_table("interface_changes")