feat(state-hub): add Extension Points and Technical Debt tracking

New entity types (DB tables, API routers, Pydantic schemas, Alembic
migration a3f1c2d4e5b6):
- extension_points: ep_id, domain, title, ep_type, status, priority,
  location, description, topic_id, workstream_id
- technical_debt: td_id, domain, title, debt_type, severity, status,
  location, description, topic_id, workstream_id

MCP server: 6 new tools — register_extension_point, list_extension_points,
update_ep_status, register_technical_debt, list_technical_debt,
update_td_status (each write emits a progress_event)

Dashboard: two new pages (extensions.md, techdept.md) with KPI sidebar,
charts, urgent-items section, and filterable card lists. Both added to
nav in observablehq.config.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 07:29:51 +01:00
parent c0f6f01bff
commit 090a206f3d
14 changed files with 1147 additions and 1 deletions

View File

@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.database import engine
from api.routers import decisions, progress, state, tasks, topics, workstreams, workstream_dependencies
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
@asynccontextmanager
@@ -32,6 +32,8 @@ app.include_router(workstreams.router)
app.include_router(workstream_dependencies.router)
app.include_router(tasks.router)
app.include_router(decisions.router)
app.include_router(extension_points.router)
app.include_router(technical_debt.router)
app.include_router(progress.router)
app.include_router(state.router)

View File

@@ -5,6 +5,8 @@ from api.models.workstream_dependency import WorkstreamDependency
from api.models.task import Task, TaskStatus, TaskPriority
from api.models.decision import Decision, DecisionType, DecisionStatus
from api.models.progress_event import ProgressEvent
from api.models.extension_point import ExtensionPoint, EPStatus
from api.models.technical_debt import TechnicalDebt, TDStatus
__all__ = [
"Base",
@@ -14,4 +16,6 @@ __all__ = [
"Task", "TaskStatus", "TaskPriority",
"Decision", "DecisionType", "DecisionStatus",
"ProgressEvent",
"ExtensionPoint", "EPStatus",
"TechnicalDebt", "TDStatus",
]

View File

@@ -0,0 +1,47 @@
import enum
import uuid
from sqlalchemy import Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class EPStatus(str, enum.Enum):
open = "open"
in_progress = "in_progress"
addressed = "addressed"
deferred = "deferred"
wont_fix = "wont_fix"
class ExtensionPoint(Base, TimestampMixin):
__tablename__ = "extension_points"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
ep_id: Mapped[str | None] = mapped_column(
String(30), nullable=True, unique=True, index=True
) # human-readable ref, e.g. EP-CUST-001
domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
location: Mapped[str | None] = mapped_column(String(500), nullable=True)
ep_type: Mapped[str] = mapped_column(
String(50), nullable=False, default="other"
) # api | schema | mcp | dashboard | architecture | integration | other
status: Mapped[EPStatus] = mapped_column(
Enum(EPStatus, name="epstatus"), nullable=False, default=EPStatus.open
)
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
)
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821

View File

@@ -0,0 +1,47 @@
import enum
import uuid
from sqlalchemy import Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class TDStatus(str, enum.Enum):
open = "open"
in_progress = "in_progress"
resolved = "resolved"
deferred = "deferred"
wont_fix = "wont_fix"
class TechnicalDebt(Base, TimestampMixin):
__tablename__ = "technical_debt"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
td_id: Mapped[str | None] = mapped_column(
String(30), nullable=True, unique=True, index=True
) # human-readable ref, e.g. TD-CUST-001
domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
location: Mapped[str | None] = mapped_column(String(500), nullable=True)
debt_type: Mapped[str] = mapped_column(
String(50), nullable=False, default="other"
) # design | implementation | test | docs | dependencies | performance | security | other
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
status: Mapped[TDStatus] = mapped_column(
Enum(TDStatus, name="tdstatus"), nullable=False, default=TDStatus.open
)
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
)
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821

View File

@@ -0,0 +1,83 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.extension_point import EPStatus, ExtensionPoint
from api.schemas.extension_point import EPCreate, EPRead, EPUpdate
router = APIRouter(prefix="/extension-points", tags=["extension-points"])
@router.get("/", response_model=list[EPRead])
async def list_eps(
domain: str | None = None,
status: EPStatus | None = None,
ep_type: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[ExtensionPoint]:
q = select(ExtensionPoint)
if domain:
q = q.where(ExtensionPoint.domain == domain)
if status:
q = q.where(ExtensionPoint.status == status)
if ep_type:
q = q.where(ExtensionPoint.ep_type == ep_type)
q = q.order_by(ExtensionPoint.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=EPRead, status_code=status.HTTP_201_CREATED)
async def create_ep(
body: EPCreate,
session: AsyncSession = Depends(get_session),
) -> ExtensionPoint:
ep = ExtensionPoint(**body.model_dump())
session.add(ep)
await session.commit()
await session.refresh(ep)
return ep
@router.get("/{ep_id}", response_model=EPRead)
async def get_ep(
ep_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> ExtensionPoint:
ep = await session.get(ExtensionPoint, ep_id)
if ep is None:
raise HTTPException(status_code=404, detail="Extension point not found")
return ep
@router.patch("/{ep_id}", response_model=EPRead)
async def update_ep(
ep_id: uuid.UUID,
body: EPUpdate,
session: AsyncSession = Depends(get_session),
) -> ExtensionPoint:
ep = await session.get(ExtensionPoint, ep_id)
if ep is None:
raise HTTPException(status_code=404, detail="Extension point not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(ep, field, value)
await session.commit()
await session.refresh(ep)
return ep
@router.delete("/{ep_id}", response_model=EPRead)
async def defer_ep(
ep_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> ExtensionPoint:
ep = await session.get(ExtensionPoint, ep_id)
if ep is None:
raise HTTPException(status_code=404, detail="Extension point not found")
ep.status = EPStatus.deferred
await session.commit()
await session.refresh(ep)
return ep

View File

@@ -0,0 +1,86 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.technical_debt import TDStatus, TechnicalDebt
from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate
router = APIRouter(prefix="/technical-debt", tags=["technical-debt"])
@router.get("/", response_model=list[TDRead])
async def list_td(
domain: str | None = None,
status: TDStatus | None = None,
debt_type: str | None = None,
severity: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[TechnicalDebt]:
q = select(TechnicalDebt)
if domain:
q = q.where(TechnicalDebt.domain == domain)
if status:
q = q.where(TechnicalDebt.status == status)
if debt_type:
q = q.where(TechnicalDebt.debt_type == debt_type)
if severity:
q = q.where(TechnicalDebt.severity == severity)
q = q.order_by(TechnicalDebt.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=TDRead, status_code=status.HTTP_201_CREATED)
async def create_td(
body: TDCreate,
session: AsyncSession = Depends(get_session),
) -> TechnicalDebt:
td = TechnicalDebt(**body.model_dump())
session.add(td)
await session.commit()
await session.refresh(td)
return td
@router.get("/{td_id}", response_model=TDRead)
async def get_td(
td_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> TechnicalDebt:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
return td
@router.patch("/{td_id}", response_model=TDRead)
async def update_td(
td_id: uuid.UUID,
body: TDUpdate,
session: AsyncSession = Depends(get_session),
) -> TechnicalDebt:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(td, field, value)
await session.commit()
await session.refresh(td)
return td
@router.delete("/{td_id}", response_model=TDRead)
async def defer_td(
td_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> TechnicalDebt:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
td.status = TDStatus.deferred
await session.commit()
await session.refresh(td)
return td

View File

@@ -4,6 +4,8 @@ from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
from api.schemas.extension_point import EPCreate, EPUpdate, EPRead
from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead
__all__ = [
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
@@ -12,4 +14,6 @@ __all__ = [
"DecisionCreate", "DecisionUpdate", "DecisionRead",
"ProgressEventCreate", "ProgressEventRead",
"StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals",
"EPCreate", "EPUpdate", "EPRead",
"TDCreate", "TDUpdate", "TDRead",
]

View File

@@ -0,0 +1,53 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from api.models.extension_point import EPStatus
VALID_DOMAINS = {
"custodian", "railiance", "markitect",
"coulomb_social", "personhood", "foerster_capabilities",
}
VALID_PRIORITIES = {"low", "medium", "high", "critical"}
class EPCreate(BaseModel):
ep_id: str | None = None
domain: str
title: str
description: str | None = None
location: str | None = None
ep_type: str = "other"
status: EPStatus = EPStatus.open
priority: str = "medium"
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
class EPUpdate(BaseModel):
title: str | None = None
description: str | None = None
location: str | None = None
ep_type: str | None = None
status: EPStatus | None = None
priority: str | None = None
workstream_id: uuid.UUID | None = None
class EPRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
ep_id: str | None = None
domain: str
title: str
description: str | None = None
location: str | None = None
ep_type: str
status: EPStatus
priority: str
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,49 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from api.models.technical_debt import TDStatus
VALID_SEVERITIES = {"low", "medium", "high", "critical"}
class TDCreate(BaseModel):
td_id: str | None = None
domain: str
title: str
description: str | None = None
location: str | None = None
debt_type: str = "other"
severity: str = "medium"
status: TDStatus = TDStatus.open
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
class TDUpdate(BaseModel):
title: str | None = None
description: str | None = None
location: str | None = None
debt_type: str | None = None
severity: str | None = None
status: TDStatus | None = None
workstream_id: uuid.UUID | None = None
class TDRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
td_id: str | None = None
domain: str
title: str
description: str | None = None
location: str | None = None
debt_type: str
severity: str
status: TDStatus
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
created_at: datetime
updated_at: datetime