diff --git a/SCOPE.md b/SCOPE.md
index 0f4b1c2..7d7a591 100644
--- a/SCOPE.md
+++ b/SCOPE.md
@@ -97,6 +97,31 @@ The Custodian is both an **operational system** (State Hub: PostgreSQL + FastAPI
---
+## Provided Capabilities
+
+```capability
+type: api
+title: MCP tool registration
+description: Register and expose new MCP tools to all Claude Code sessions via the state-hub server.
+keywords: [mcp, tool, api, registration, server]
+```
+
+```capability
+type: data
+title: Cross-domain state tracking
+description: Track workstreams, tasks, decisions, and progress events across all six project domains.
+keywords: [state, tracking, workstream, task, decision, progress]
+```
+
+```capability
+type: api
+title: SBOM and licence reporting
+description: Ingest lockfiles from any repo and provide aggregated SBOM and copyleft licence risk reports.
+keywords: [sbom, licence, license, dependency, lockfile, copyleft]
+```
+
+---
+
## Notes
Dependency order for domain sequencing: Railiance → Markitect → Coulomb.social → Personhood/Foerster → Custodian. The consistency checker (`make fix-consistency REPO=the-custodian`) must be run after any workplan changes to keep the dashboard accurate.
diff --git a/state-hub/Makefile b/state-hub/Makefile
index 9016020..7712e70 100644
--- a/state-hub/Makefile
+++ b/state-hub/Makefile
@@ -169,6 +169,20 @@ ingest-sbom:
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
$(if $(DRY_RUN),--dry-run)
+## Ingest capability declarations from SCOPE.md into the catalog.
+## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian]
+## Or: make ingest-capabilities-all
+## Add DRY_RUN=1 to preview without writing.
+ingest-capabilities:
+ @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
+ uv run python scripts/ingest_capabilities.py --repo "$(REPO)" \
+ $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
+ $(if $(DRY_RUN),--dry-run)
+
+ingest-capabilities-all:
+ uv run python scripts/ingest_capabilities.py --all \
+ $(if $(DRY_RUN),--dry-run)
+
## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml.
## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra]
## Add DRY_RUN=1 to preview without writing.
diff --git a/state-hub/api/main.py b/state-hub/api/main.py
index e73200f..605bce8 100644
--- a/state-hub/api/main.py
+++ b/state-hub/api/main.py
@@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
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
+from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests
@asynccontextmanager
@@ -47,6 +47,7 @@ app.include_router(repo_goals.router)
app.include_router(contributions.router)
app.include_router(sbom.router)
app.include_router(messages.router)
+app.include_router(capability_requests.router)
app.include_router(state.router)
app.include_router(policy.router)
diff --git a/state-hub/api/models/__init__.py b/state-hub/api/models/__init__.py
index 4b8a7a5..4b7e6f9 100644
--- a/state-hub/api/models/__init__.py
+++ b/state-hub/api/models/__init__.py
@@ -15,6 +15,8 @@ from api.models.contribution import Contribution, ContributionType, Contribution
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.sbom_entry import SBOMEntry, Ecosystem
from api.models.agent_message import AgentMessage
+from api.models.capability_catalog import CapabilityCatalog
+from api.models.capability_request import CapabilityRequest
__all__ = [
"Base",
@@ -34,4 +36,6 @@ __all__ = [
"SBOMSnapshot",
"SBOMEntry", "Ecosystem",
"AgentMessage",
+ "CapabilityCatalog",
+ "CapabilityRequest",
]
diff --git a/state-hub/api/models/capability_catalog.py b/state-hub/api/models/capability_catalog.py
new file mode 100644
index 0000000..5f1bcd7
--- /dev/null
+++ b/state-hub/api/models/capability_catalog.py
@@ -0,0 +1,39 @@
+import uuid
+
+from sqlalchemy import ARRAY, ForeignKey, String, Text, UniqueConstraint
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from api.models.base import Base, TimestampMixin, new_uuid
+
+
+class CapabilityCatalog(Base, TimestampMixin):
+ __tablename__ = "capability_catalog"
+ __table_args__ = (
+ UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
+ )
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=new_uuid
+ )
+ domain_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("domains.id", ondelete="RESTRICT"),
+ nullable=False,
+ index=True,
+ )
+ capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ keywords: Mapped[list[str]] = mapped_column(
+ ARRAY(String), nullable=False, server_default="{}"
+ )
+ status: Mapped[str] = mapped_column(
+ String(20), nullable=False, default="active", server_default="active"
+ )
+
+ domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
+
+ @property
+ def domain_slug(self) -> str:
+ return self.domain.slug if self.domain is not None else ""
diff --git a/state-hub/api/models/capability_request.py b/state-hub/api/models/capability_request.py
new file mode 100644
index 0000000..c890d3e
--- /dev/null
+++ b/state-hub/api/models/capability_request.py
@@ -0,0 +1,93 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, 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 CapabilityRequest(Base, TimestampMixin):
+ __tablename__ = "capability_requests"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=new_uuid
+ )
+ title: Mapped[str] = mapped_column(String(500), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ priority: Mapped[str] = mapped_column(
+ String(20), nullable=False, default="medium", server_default="medium"
+ )
+ status: Mapped[str] = mapped_column(
+ String(20), nullable=False, default="requested", server_default="requested"
+ )
+
+ # Requester side
+ requesting_domain_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("domains.id", ondelete="RESTRICT"),
+ nullable=False,
+ index=True,
+ )
+ requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("workstreams.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
+
+ # Fulfiller side (populated on accept / auto-route)
+ fulfilling_domain_id: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("domains.id", ondelete="SET NULL"),
+ nullable=True,
+ index=True,
+ )
+ fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("workstreams.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
+
+ # Links
+ blocking_task_id: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("tasks.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("capability_catalog.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+
+ resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+ accepted_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ completed_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+
+ # Relationships
+ requesting_domain: Mapped["Domain"] = relationship( # noqa: F821
+ "Domain", foreign_keys=[requesting_domain_id], lazy="selectin"
+ )
+ fulfilling_domain: Mapped["Domain | None"] = relationship( # noqa: F821
+ "Domain", foreign_keys=[fulfilling_domain_id], lazy="selectin"
+ )
+ blocking_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
+ catalog_entry: Mapped["CapabilityCatalog | None"] = relationship( # noqa: F821
+ "CapabilityCatalog", lazy="selectin"
+ )
+
+ @property
+ def requesting_domain_slug(self) -> str:
+ return self.requesting_domain.slug if self.requesting_domain else ""
+
+ @property
+ def fulfilling_domain_slug(self) -> str | None:
+ return self.fulfilling_domain.slug if self.fulfilling_domain else None
diff --git a/state-hub/api/routers/capability_requests.py b/state-hub/api/routers/capability_requests.py
new file mode 100644
index 0000000..fda2b3d
--- /dev/null
+++ b/state-hub/api/routers/capability_requests.py
@@ -0,0 +1,358 @@
+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.capability_catalog import CapabilityCatalog
+from api.models.capability_request import CapabilityRequest
+from api.models.domain import Domain
+from api.models.task import Task
+from api.schemas.capability_request import (
+ CatalogCreate,
+ CatalogRead,
+ CapabilityRequestAccept,
+ CapabilityRequestCreate,
+ CapabilityRequestRead,
+ CapabilityRequestStatusPatch,
+)
+
+router = APIRouter(tags=["capability-requests"])
+
+# ---------------------------------------------------------------------------
+# Lifecycle guard
+# ---------------------------------------------------------------------------
+
+_VALID_TRANSITIONS: dict[str, set[str]] = {
+ "requested": {"accepted", "rejected", "withdrawn"},
+ "accepted": {"in_progress", "rejected", "withdrawn"},
+ "in_progress": {"ready_for_review", "rejected", "withdrawn"},
+ "ready_for_review": {"completed", "in_progress", "withdrawn"},
+ "completed": set(),
+ "rejected": set(),
+ "withdrawn": set(),
+}
+
+
+# ---------------------------------------------------------------------------
+# Capability Catalog endpoints
+# ---------------------------------------------------------------------------
+
+@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 _resolve_domain(body.domain, session)
+ entry = CapabilityCatalog(
+ domain_id=domain.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}' 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"),
+ session: AsyncSession = Depends(get_session),
+) -> list[CapabilityCatalog]:
+ q = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
+ if domain:
+ d = await _resolve_domain(domain, session)
+ q = q.where(CapabilityCatalog.domain_id == d.id)
+ if capability_type:
+ q = q.where(CapabilityCatalog.capability_type == capability_type)
+ if status_filter and status_filter != "all":
+ q = q.where(CapabilityCatalog.status == status_filter)
+ elif not status_filter:
+ q = q.where(CapabilityCatalog.status == "active")
+ result = await session.execute(q)
+ return list(result.scalars().all())
+
+
+# ---------------------------------------------------------------------------
+# Capability Request endpoints
+# ---------------------------------------------------------------------------
+
+@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
+async def create_request(
+ body: CapabilityRequestCreate,
+ session: AsyncSession = Depends(get_session),
+) -> CapabilityRequest:
+ req_domain = await _resolve_domain(body.requesting_domain, session)
+
+ # Route to provider
+ fulfilling_domain_id, catalog_entry_id = await _route_capability(
+ session, body.capability_type, body.description or ""
+ )
+
+ req = CapabilityRequest(
+ title=body.title,
+ description=body.description,
+ capability_type=body.capability_type,
+ priority=body.priority,
+ requesting_domain_id=req_domain.id,
+ requesting_agent=body.requesting_agent,
+ requesting_workstream_id=body.requesting_workstream_id,
+ blocking_task_id=body.blocking_task_id,
+ fulfilling_domain_id=fulfilling_domain_id,
+ catalog_entry_id=catalog_entry_id,
+ )
+ session.add(req)
+ await session.flush() # get req.id before creating notification
+
+ # Auto-notify
+ if fulfilling_domain_id:
+ ful_domain = await session.get(Domain, fulfilling_domain_id)
+ to_agent = ful_domain.slug if ful_domain else "broadcast"
+ else:
+ to_agent = "broadcast"
+
+ _add_notification(
+ session,
+ from_agent="system",
+ to_agent=to_agent,
+ subject=f"[capability-request] {body.title}",
+ body=(
+ f"New capability request from **{body.requesting_agent}** "
+ f"({body.requesting_domain}):\n\n"
+ f"**Type:** {body.capability_type}\n"
+ f"**Priority:** {body.priority}\n\n"
+ f"{body.description or '(no description)'}"
+ ),
+ )
+
+ await session.commit()
+ await session.refresh(req)
+ return req
+
+
+@router.get("/capability-requests/", response_model=list[CapabilityRequestRead])
+async def list_requests(
+ domain: str | None = Query(None, description="Filter by requesting OR fulfilling domain slug"),
+ status_filter: str | None = Query(None, alias="status"),
+ capability_type: str | None = Query(None),
+ session: AsyncSession = Depends(get_session),
+) -> list[CapabilityRequest]:
+ q = select(CapabilityRequest).order_by(CapabilityRequest.created_at.desc())
+ if domain:
+ d = await _resolve_domain(domain, session)
+ q = q.where(
+ (CapabilityRequest.requesting_domain_id == d.id)
+ | (CapabilityRequest.fulfilling_domain_id == d.id)
+ )
+ if status_filter:
+ q = q.where(CapabilityRequest.status == status_filter)
+ if capability_type:
+ q = q.where(CapabilityRequest.capability_type == capability_type)
+ result = await session.execute(q)
+ return list(result.scalars().all())
+
+
+@router.get("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
+async def get_request(
+ request_id: uuid.UUID,
+ session: AsyncSession = Depends(get_session),
+) -> CapabilityRequest:
+ return await _get_request_or_404(request_id, session)
+
+
+@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
+async def accept_request(
+ request_id: uuid.UUID,
+ body: CapabilityRequestAccept,
+ session: AsyncSession = Depends(get_session),
+) -> CapabilityRequest:
+ req = await _get_request_or_404(request_id, session)
+ _check_transition(req.status, "accepted")
+
+ now = datetime.now(tz=timezone.utc)
+ req.status = "accepted"
+ req.fulfilling_agent = body.fulfilling_agent
+ req.fulfilling_workstream_id = body.fulfilling_workstream_id
+ req.accepted_at = now
+
+ # If no fulfilling domain was set by routing, infer from the accepting agent's context
+ # (The agent can also PATCH it later if needed)
+
+ _add_notification(
+ session,
+ from_agent=body.fulfilling_agent,
+ to_agent=req.requesting_agent,
+ subject=f"[capability-accepted] {req.title}",
+ body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
+ )
+
+ await session.commit()
+ await session.refresh(req)
+ return req
+
+
+@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
+async def patch_request_status(
+ request_id: uuid.UUID,
+ body: CapabilityRequestStatusPatch,
+ session: AsyncSession = Depends(get_session),
+) -> CapabilityRequest:
+ req = await _get_request_or_404(request_id, session)
+ _check_transition(req.status, body.status)
+
+ req.status = body.status
+ if body.note:
+ req.resolution_note = body.note
+
+ now = datetime.now(tz=timezone.utc)
+
+ # Status-specific side effects
+ if body.status == "completed":
+ req.completed_at = now
+ # Auto-unblock the blocking task
+ if req.blocking_task_id:
+ task = await session.get(Task, req.blocking_task_id)
+ if task and task.status == "blocked":
+ task.status = "todo"
+ task.blocking_reason = None
+
+ _add_notification(
+ session,
+ from_agent="system",
+ to_agent=req.requesting_agent,
+ subject=f"[capability-completed] {req.title}",
+ body=(
+ f"Capability request **{req.title}** has been completed.\n\n"
+ f"{body.note or ''}"
+ ),
+ )
+ elif body.status == "ready_for_review":
+ _add_notification(
+ session,
+ from_agent=req.fulfilling_agent or "system",
+ to_agent=req.requesting_agent,
+ subject=f"[capability-ready] {req.title} -- please review",
+ body=(
+ f"Capability **{req.title}** is ready for your review and optimization.\n\n"
+ f"{body.note or ''}"
+ ),
+ )
+ elif body.status == "rejected":
+ _add_notification(
+ session,
+ from_agent=req.fulfilling_agent or "system",
+ to_agent=req.requesting_agent,
+ subject=f"[capability-rejected] {req.title}",
+ body=(
+ f"Capability request **{req.title}** has been rejected.\n\n"
+ f"**Reason:** {body.note or '(no reason given)'}"
+ ),
+ )
+ elif body.status == "in_progress":
+ _add_notification(
+ session,
+ from_agent=req.fulfilling_agent or "system",
+ to_agent=req.requesting_agent,
+ subject=f"[capability-in-progress] {req.title}",
+ body=f"Work on capability **{req.title}** is now in progress.",
+ )
+
+ await session.commit()
+ await session.refresh(req)
+ return req
+
+
+# ---------------------------------------------------------------------------
+# Routing algorithm
+# ---------------------------------------------------------------------------
+
+async def _route_capability(
+ session: AsyncSession, capability_type: str, description: str
+) -> tuple[uuid.UUID | None, uuid.UUID | None]:
+ """Find the best-matching domain for a capability request.
+
+ Returns (domain_id, catalog_entry_id) or (None, None) for broadcast.
+ """
+ q = select(CapabilityCatalog).where(
+ CapabilityCatalog.capability_type == capability_type,
+ CapabilityCatalog.status == "active",
+ )
+ entries = list((await session.execute(q)).scalars().all())
+
+ if len(entries) == 1:
+ return entries[0].domain_id, entries[0].id
+
+ if len(entries) > 1 and description:
+ desc_lower = description.lower()
+ scored: list[tuple[int, CapabilityCatalog]] = []
+ for entry in entries:
+ score = sum(1 for kw in (entry.keywords or []) if kw.lower() in desc_lower)
+ scored.append((score, entry))
+ scored.sort(key=lambda x: -x[0])
+ if scored[0][0] > 0 and (len(scored) < 2 or scored[0][0] > scored[1][0]):
+ return scored[0][1].domain_id, scored[0][1].id
+
+ return None, None
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _add_notification(
+ session: AsyncSession,
+ from_agent: str,
+ to_agent: str,
+ subject: str,
+ body: str,
+) -> None:
+ """Create an AgentMessage notification in the current session (no commit)."""
+ msg = AgentMessage(
+ from_agent=from_agent,
+ to_agent=to_agent,
+ subject=subject,
+ body=body,
+ )
+ session.add(msg)
+
+
+async def _resolve_domain(slug: str, session: AsyncSession) -> Domain:
+ result = await session.execute(select(Domain).where(Domain.slug == slug))
+ domain = result.scalar_one_or_none()
+ if domain is None:
+ raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
+ return domain
+
+
+async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
+ req = await session.get(CapabilityRequest, request_id)
+ if req is None:
+ raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
+ return req
+
+
+def _check_transition(current: str, target: str) -> None:
+ allowed = _VALID_TRANSITIONS.get(current, set())
+ if target not in allowed:
+ raise HTTPException(
+ status_code=422,
+ detail=(
+ f"Cannot transition from '{current}' to '{target}'. "
+ f"Allowed: {sorted(allowed) or 'none (terminal state)'}"
+ ),
+ )
diff --git a/state-hub/api/routers/state.py b/state-hub/api/routers/state.py
index 715bb3f..f890450 100644
--- a/state-hub/api/routers/state.py
+++ b/state-hub/api/routers/state.py
@@ -6,6 +6,7 @@ from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session, engine
+from api.models.capability_request import CapabilityRequest
from api.models.contribution import Contribution, ContributionStatus, ContributionType
from api.models.decision import Decision, DecisionStatus, DecisionType
from api.models.domain import Domain
@@ -204,6 +205,13 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS)
)
+ # Open capability requests (non-terminal statuses)
+ open_cap_req_count = (await session.execute(
+ select(func.count()).select_from(CapabilityRequest).where(
+ CapabilityRequest.status.in_(["requested", "accepted", "in_progress", "ready_for_review"])
+ )
+ )).scalar() or 0
+
return StateSummary(
generated_at=datetime.now(tz=timezone.utc),
totals=totals,
@@ -215,6 +223,7 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
domains=domain_summaries,
contribution_counts=contribution_counts,
licence_risk_count=licence_risk_count,
+ open_capability_requests=open_cap_req_count,
open_workstreams=[
WorkstreamWithDeps(
**WorkstreamRead.model_validate(w).model_dump(),
diff --git a/state-hub/api/schemas/capability_request.py b/state-hub/api/schemas/capability_request.py
new file mode 100644
index 0000000..e1f01f1
--- /dev/null
+++ b/state-hub/api/schemas/capability_request.py
@@ -0,0 +1,79 @@
+import uuid
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict
+
+
+# ---------------------------------------------------------------------------
+# Capability Catalog schemas
+# ---------------------------------------------------------------------------
+
+class CatalogCreate(BaseModel):
+ domain: str # slug, resolved to domain_id in router
+ capability_type: str
+ title: str
+ description: str | None = None
+ keywords: list[str] = []
+
+
+class CatalogRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: uuid.UUID
+ domain_slug: str
+ capability_type: str
+ title: str
+ description: str | None = None
+ keywords: list[str] = []
+ status: str
+ created_at: datetime
+ updated_at: datetime
+
+
+# ---------------------------------------------------------------------------
+# Capability Request schemas
+# ---------------------------------------------------------------------------
+
+class CapabilityRequestCreate(BaseModel):
+ title: str
+ description: str | None = None
+ capability_type: str
+ priority: str = "medium"
+ requesting_domain: str # slug, resolved to domain_id in router
+ requesting_agent: str
+ requesting_workstream_id: uuid.UUID | None = None
+ blocking_task_id: uuid.UUID | None = None
+
+
+class CapabilityRequestAccept(BaseModel):
+ fulfilling_agent: str
+ fulfilling_workstream_id: uuid.UUID | None = None
+
+
+class CapabilityRequestStatusPatch(BaseModel):
+ status: str # in_progress | ready_for_review | completed | rejected | withdrawn
+ note: str | None = None
+
+
+class CapabilityRequestRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: uuid.UUID
+ title: str
+ description: str | None = None
+ capability_type: str
+ priority: str
+ status: str
+ requesting_domain_slug: str
+ requesting_agent: str
+ requesting_workstream_id: uuid.UUID | None = None
+ fulfilling_domain_slug: str | None = None
+ fulfilling_agent: str | None = None
+ fulfilling_workstream_id: uuid.UUID | None = None
+ blocking_task_id: uuid.UUID | None = None
+ catalog_entry_id: uuid.UUID | None = None
+ resolution_note: str | None = None
+ accepted_at: datetime | None = None
+ completed_at: datetime | None = None
+ created_at: datetime
+ updated_at: datetime
diff --git a/state-hub/api/schemas/state.py b/state-hub/api/schemas/state.py
index 6d49e6d..5f21777 100644
--- a/state-hub/api/schemas/state.py
+++ b/state-hub/api/schemas/state.py
@@ -79,3 +79,4 @@ class StateSummary(BaseModel):
domains: list[DomainSummary] = []
contribution_counts: dict[str, int] = {}
licence_risk_count: int = 0
+ open_capability_requests: int = 0
diff --git a/state-hub/dashboard/observablehq.config.js b/state-hub/dashboard/observablehq.config.js
index a6159f5..f975898 100644
--- a/state-hub/dashboard/observablehq.config.js
+++ b/state-hub/dashboard/observablehq.config.js
@@ -19,6 +19,7 @@ export default {
pages: [
// ── Pages (Overview first, then alphabetical) ────────────────────────────
{ name: "Overview", path: "/" },
+ { name: "Capabilities", path: "/capability-requests" },
{ name: "Contributions", path: "/contributions" },
{ name: "Domains", path: "/domains" },
{ name: "Goals", path: "/goals" },
@@ -66,6 +67,7 @@ export default {
collapsible: true,
open: false,
pages: [
+ { name: "Capabilities", path: "/docs/capabilities" },
{ name: "Connecting to the Hub", path: "/docs/connecting" },
{ name: "Contributions", path: "/docs/contributions" },
{ name: "Decision Health", path: "/docs/decisions-kpi" },
@@ -84,6 +86,7 @@ export default {
{ name: "Repo Integration", path: "/docs/repo-integration" },
{ name: "Repos", path: "/docs/repos" },
{ name: "SBOM", path: "/docs/sbom" },
+ { name: "SCOPE.md", path: "/docs/scope" },
{ name: "Tasks", path: "/docs/tasks" },
{ name: "Technical Debt", path: "/docs/debt" },
{ name: "Todo", path: "/docs/todo" },
diff --git a/state-hub/dashboard/src/capability-requests.md b/state-hub/dashboard/src/capability-requests.md
new file mode 100644
index 0000000..f7782d2
--- /dev/null
+++ b/state-hub/dashboard/src/capability-requests.md
@@ -0,0 +1,250 @@
+---
+title: Capability Requests
+---
+
+```js
+import {API} from "./components/config.js";
+const POLL = 30_000;
+```
+
+```js
+// Live poll for capability requests
+const reqState = (async function*() {
+ while (true) {
+ let data = [], ok = false;
+ try {
+ const r = await fetch(`${API}/capability-requests/`);
+ ok = r.ok;
+ data = ok ? await r.json() : [];
+ } catch {}
+ yield {data, ok, ts: new Date()};
+ await new Promise(res => setTimeout(res, POLL));
+ }
+})();
+```
+
+```js
+const requests = reqState.data ?? [];
+const _ok = reqState.ok ?? false;
+const _ts = reqState.ts;
+```
+
+# Capability Requests
+
+```js
+import {injectTocTop} from "./components/toc-sidebar.js";
+import {withDocHelp} from "./components/doc-overlay.js";
+
+const _liveEl = html`
+ ●
+ ${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`API offline`}
+
`;
+withDocHelp(_liveEl, "/docs/live-data");
+injectTocTop("live-indicator", _liveEl);
+
+const _h1 = document.querySelector("#observablehq-main h1");
+if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/capabilities"); }
+```
+
+```js
+// KPI sidebar
+const open = requests.filter(r => ["requested","accepted","in_progress","ready_for_review"].includes(r.status));
+const completed = requests.filter(r => r.status === "completed");
+const avgFulfill = completed.length > 0
+ ? (completed.reduce((s, r) => s + (new Date(r.completed_at) - new Date(r.created_at)), 0) / completed.length / 86400000).toFixed(1)
+ : "—";
+const critical = open.filter(r => r.priority === "critical" || r.priority === "high").length;
+
+const kpiEl = html`
+
Capability Requests
+
+ Open${open.length}
+ Avg fulfill${avgFulfill}d
+ High/Critical${critical}
+ Total${requests.length}
+
+
`;
+injectTocTop("cap-req-kpi", kpiEl);
+```
+
+```js
+// Filters
+const typeFilter = Inputs.select(
+ ["all", ...new Set(requests.map(r => r.capability_type))],
+ {label: "Type", value: "all"}
+);
+const statFilter = Inputs.select(
+ ["all", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"],
+ {label: "Status", value: "all"}
+);
+const domFilter = Inputs.select(
+ ["all", ...new Set([...requests.map(r => r.requesting_domain_slug), ...requests.map(r => r.fulfilling_domain_slug).filter(Boolean)])],
+ {label: "Domain", value: "all"}
+);
+display(html`
+ ${typeFilter}${statFilter}${domFilter}
+
`);
+```
+
+```js
+const tf = typeFilter.value;
+const sf = statFilter.value;
+const df = domFilter.value;
+
+const filtered = requests.filter(r =>
+ (tf === "all" || r.capability_type === tf) &&
+ (sf === "all" || r.status === sf) &&
+ (df === "all" || r.requesting_domain_slug === df || r.fulfilling_domain_slug === df)
+);
+```
+
+## Summary
+
+```js
+const priorityColors = {critical: "#e53935", high: "orange", medium: "steelblue", low: "#aaa"};
+display(html`
+
Requested
${requests.filter(r => r.status === "requested").length}
+
In Progress
${requests.filter(r => ["accepted","in_progress"].includes(r.status)).length}
+
Ready for Review
${requests.filter(r => r.status === "ready_for_review").length}
+
Completed
${completed.length}
+
`);
+```
+
+## Status Kanban
+
+```js
+const statusCols = [
+ {key: "requested", label: "Requested", color: "steelblue"},
+ {key: "accepted", label: "Accepted", color: "#f0a500"},
+ {key: "in_progress", label: "In Progress", color: "#2196f3"},
+ {key: "ready_for_review", label: "Ready for Review", color: "#4caf50"},
+ {key: "completed", label: "Completed", color: "#2e7d32"},
+ {key: "rejected", label: "Rejected", color: "#e53935"},
+ {key: "withdrawn", label: "Withdrawn", color: "#bbb"},
+];
+
+const colMap = {};
+for (const r of filtered) {
+ (colMap[r.status] = colMap[r.status] ?? []).push(r);
+}
+
+const activeCols = statusCols.filter(s => colMap[s.key]?.length);
+if (activeCols.length === 0) {
+ display(html`No capability requests match the current filters.
`);
+} else {
+ const ageDays = (r) => ((Date.now() - new Date(r.created_at)) / 86400000).toFixed(0);
+ display(html`
+ ${activeCols.map(s => html`
+
+
+ ${colMap[s.key].map(r => html`
+
+
${r.capability_type}
+
${r.priority}
+
${r.title}
+
+ ${r.requesting_domain_slug}
+ ${r.fulfilling_domain_slug ? html` → ${r.fulfilling_domain_slug}` : html` → unassigned`}
+
+
${ageDays(r)}d old
+
+ `)}
+
+ `)}
+
`);
+}
+```
+
+## All Requests
+
+```js
+display(Inputs.table(filtered.map(r => ({
+ Type: r.capability_type,
+ Title: r.title,
+ Priority: r.priority,
+ Status: r.status,
+ Requester: r.requesting_domain_slug,
+ Provider: r.fulfilling_domain_slug ?? "—",
+ Agent: r.requesting_agent,
+ Created: new Date(r.created_at).toLocaleDateString(),
+})), {maxWidth: 1000}));
+```
+
+---
+
+## Capability Catalog
+
+```js
+// Live poll for catalog entries
+const catalogState = (async function*() {
+ while (true) {
+ let data = [];
+ try {
+ const r = await fetch(`${API}/capability-catalog/?status=all`);
+ if (r.ok) data = await r.json();
+ } catch {}
+ yield data;
+ await new Promise(res => setTimeout(res, POLL));
+ }
+})();
+```
+
+```js
+const catalog = catalogState ?? [];
+```
+
+```js
+if (catalog.length === 0) {
+ display(html`No capabilities registered yet. Add ```capability blocks to SCOPE.md files and run make ingest-capabilities-all.
`);
+} else {
+ // Group by domain
+ const byDomain = {};
+ for (const c of catalog) {
+ (byDomain[c.domain_slug] = byDomain[c.domain_slug] ?? []).push(c);
+ }
+ const typeColors = {
+ infrastructure: "#e65100", api: "#1565c0", data: "#2e7d32",
+ security: "#c62828", documentation: "#6a1b9a", other: "#888"
+ };
+ display(html`
+ ${Object.entries(byDomain).sort((a, b) => a[0].localeCompare(b[0])).map(([domain, caps]) => html`
+
+
${domain} ${caps.length}
+ ${caps.map(c => html`
+
+
${c.capability_type}
+
${c.title}
+ ${c.description ? html`
${c.description}
` : ""}
+ ${c.keywords?.length ? html`
${c.keywords.map(k => html`${k}`)}
` : ""}
+
+ `)}
+
+ `)}
+
`);
+}
+```
+
+
diff --git a/state-hub/dashboard/src/docs/capabilities.md b/state-hub/dashboard/src/docs/capabilities.md
new file mode 100644
index 0000000..8b1893c
--- /dev/null
+++ b/state-hub/dashboard/src/docs/capabilities.md
@@ -0,0 +1,228 @@
+---
+title: Capabilities — Reference
+---
+
+# Capabilities — Reference
+
+The Capability Requests page shows cross-domain provisioning requests — a
+decoupled mechanism for one domain to request something that another domain is
+responsible for, without needing to know *who* is responsible.
+
+---
+
+## What is a capability?
+
+A **capability** is something a domain can provide to the broader ecosystem —
+infrastructure provisioning, API endpoints, security tooling, documentation,
+data pipelines, etc. Capabilities are registered in the **capability catalog**
+so the system knows which domain provides what.
+
+A **capability request** is a structured declaration from a requester
+("I need X") that the system routes to the right provider automatically.
+
+---
+
+## Capability catalog
+
+The catalog is the routing backbone. Each entry registers one thing a domain
+can provide.
+
+**Origin of truth: SCOPE.md** — following ADR-001, capability declarations
+live in each repo's `SCOPE.md` file under the `## Provided Capabilities`
+section. The state-hub catalog table is a derived index, reconstructable from
+repo files via `make ingest-capabilities-all`.
+
+### SCOPE.md capability blocks
+
+Add fenced `capability` blocks to your repo's SCOPE.md:
+
+````markdown
+## Provided Capabilities
+
+```capability
+type: infrastructure
+title: Cluster provisioning
+description: Provision k8s clusters and managed instances for any domain.
+keywords: [cluster, k8s, privacy, instance]
+```
+````
+
+| Field | Purpose |
+|-------|---------|
+| **type** | Category — `infrastructure`, `api`, `data`, `security`, `documentation`, `other` |
+| **title** | Short name (unique within domain + type) |
+| **description** | What this capability provides, in one or two sentences |
+| **keywords** | Routing hints matched against request descriptions |
+
+### Ingesting into the catalog
+
+```bash
+make ingest-capabilities REPO=the-custodian # single repo
+make ingest-capabilities-all # all registered repos
+make ingest-capabilities REPO=railiance-infra DRY_RUN=1 # preview
+```
+
+The ingest script reads `SCOPE.md` → parses `capability` blocks → upserts into
+the `capability_catalog` table via the API. Existing entries (same domain + type
++ title) are skipped.
+
+### Browsing the catalog
+
+Via MCP:
+```
+list_capabilities(domain="railiance")
+```
+
+Via API:
+```
+GET /capability-catalog/?domain=railiance
+```
+
+The catalog is also shown at the bottom of the Capabilities dashboard page,
+grouped by domain with type badges and keyword tags.
+
+---
+
+## Routing algorithm
+
+When a request is created, the system auto-routes it:
+
+1. **Exact type match** — find catalog entries where `capability_type` matches
+2. **Single match** — auto-assign the providing domain
+3. **Multiple matches** — keyword-score the request description against each entry's keywords; pick the winner if unambiguous
+4. **No match or tie** — leave the provider unassigned and **broadcast** a notification to all domains so one can claim it
+
+This means the requester never needs to know which domain owns a capability.
+
+---
+
+## Request lifecycle
+
+```
+requested → accepted → in_progress → ready_for_review → completed
+ ↓ ↓ ↓ ↓
+ withdrawn rejected withdrawn withdrawn
+ withdrawn
+```
+
+| Status | Meaning |
+|--------|---------|
+| **requested** | Need declared; routed (or broadcast) to provider |
+| **accepted** | Provider acknowledged and claimed the request |
+| **in_progress** | Provider is actively working on it |
+| **ready_for_review** | Provider finished; requester should review and optimise |
+| **completed** | Requester confirmed; capability is live |
+| **rejected** | Provider cannot or will not fulfil the request |
+| **withdrawn** | Requester cancelled the request |
+
+Transitions are enforced by the API — you cannot skip stages. Terminal states
+(`completed`, `rejected`, `withdrawn`) allow no further transitions.
+
+---
+
+## Auto-notifications
+
+Every lifecycle transition creates an **AgentMessage** atomically:
+
+| Transition | Notification to |
+|------------|----------------|
+| **requested** | Provider domain agent (or `broadcast` if unrouted) |
+| **accepted** | Requesting agent |
+| **in_progress** | Requesting agent |
+| **ready_for_review** | Requesting agent |
+| **completed** | Requesting agent |
+| **rejected** | Requesting agent (with reason) |
+
+Notifications appear in the [Inbox](/inbox) page and are queryable via
+`get_messages(to_agent="")`.
+
+---
+
+## Auto-unblock
+
+A request can optionally link to a **blocking task** via `blocking_task_id`.
+When the request reaches `completed`, the system automatically patches that
+task from `blocked` → `todo` and clears its `blocking_reason`. This means
+blocked work resumes without manual intervention.
+
+---
+
+## Creating a request
+
+Via MCP:
+
+```
+request_capability(
+ title = "Privacy idea instance on cluster",
+ description = "Need a privacy idea instance provisioned on the k8s cluster",
+ capability_type = "infrastructure",
+ requesting_agent = "net-kingdom-worker",
+ requesting_domain = "custodian",
+ requesting_workstream_id = "", # optional
+ priority = "high", # low | medium | high | critical
+ blocking_task_id = "" # optional — auto-unblocked on completion
+)
+```
+
+The system routes this to `railiance` (if a matching catalog entry exists),
+creates an AgentMessage notification, and returns the request with
+`fulfilling_domain_slug: "railiance"`.
+
+---
+
+## Accepting and fulfilling
+
+The provider agent checks their inbox, sees the request, and accepts:
+
+```
+accept_capability_request(
+ request_id = "",
+ fulfilling_agent = "railiance-worker",
+ fulfilling_workstream_id = "" # optional
+)
+```
+
+Then advances through the lifecycle:
+
+```
+update_capability_request_status(request_id, "in_progress")
+update_capability_request_status(request_id, "ready_for_review", note="Instance up at 10.0.1.42")
+```
+
+The requester reviews and completes:
+
+```
+update_capability_request_status(request_id, "completed", note="Verified, looks good")
+```
+
+---
+
+## Dashboard
+
+The Capabilities page shows:
+
+- **KPI sidebar** — open count, average fulfillment time, high/critical count
+- **Summary cards** — requested, in progress, ready for review, completed
+- **Kanban board** — cards grouped by status column
+- **Table** — all requests with filters by type, status, and domain
+
+Each card shows the capability type, priority, requester → provider domains,
+and age in days.
+
+---
+
+## Relation to other concepts
+
+| Concept | Relationship |
+|---------|-------------|
+| **SCOPE.md** | Defines what a repo *is responsible for* — the catalog registers what it *can provide* |
+| **Dependencies** | Workstream-to-workstream edges — capabilities are higher-level, domain-to-domain |
+| **Extension Points** | Design forks for *future* enhancement — capabilities are *operational* requests |
+| **Contributions** | Outbound upstream work — capabilities are *inbound* requests between internal domains |
+| **Human Interventions** | Flagged tasks for Bernd — capabilities are agent-to-agent coordination |
+
+---
+
+*Capability requests are a sanctioned write use case of the State Hub alongside
+`resolve_decision` and `get_next_steps`. They do not originate in workplan files —
+they are operational coordination.*
diff --git a/state-hub/dashboard/src/docs/scope.md b/state-hub/dashboard/src/docs/scope.md
new file mode 100644
index 0000000..fcef9bf
--- /dev/null
+++ b/state-hub/dashboard/src/docs/scope.md
@@ -0,0 +1,177 @@
+---
+title: SCOPE.md — Reference
+---
+
+# SCOPE.md — Reference
+
+SCOPE.md is a lightweight, strategic orientation artifact placed at the root of
+every registered repository. It helps humans and agents quickly understand what
+a repo is about, when it is relevant, and where it fits in the ecosystem.
+
+---
+
+## What SCOPE.md is
+
+SCOPE.md answers these questions **in under 60 seconds**:
+
+- What is this repository for?
+- Should I care about it right now?
+- When is it relevant to my work?
+- Where does it fit in the ecosystem?
+- Is it mature enough to trust or reuse?
+- Does it overlap with something else?
+
+It is **not** a README, not architecture documentation, and not marketing text.
+It is a pragmatic, scannable boundary definition.
+
+---
+
+## Template structure
+
+Every SCOPE.md follows an 11-section template:
+
+| Section | Purpose |
+|---------|---------|
+| **One-liner** | One precise sentence describing the repo's purpose |
+| **Core Idea** | Main capability and what problem it solves |
+| **In Scope** | What the repo is explicitly responsible for — concrete, not vague |
+| **Out of Scope** | What it deliberately does NOT do (often more important) |
+| **Relevant When** | Real usage scenarios when someone should consider this repo |
+| **Not Relevant When** | When someone should look elsewhere |
+| **Current State** | Maturity indicators: status, implementation, stability, usage |
+| **How It Fits** | Upstream dependencies, downstream consumers, often-used-with |
+| **Terminology** | Domain terms, potential confusions with similar concepts |
+| **Related / Overlapping** | Repos with similar or adjacent responsibilities |
+| **Provided Capabilities** | What this repo's domain can provide to others on request |
+| **Getting Oriented** | Entry points, key files, where to start |
+
+The template is at `state-hub/scripts/project_rules/scope.template`.
+
+---
+
+## Current State indicators
+
+The Current State section uses four axes:
+
+| Axis | Values |
+|------|--------|
+| **Status** | concept / experimental / active / stable / deprecated |
+| **Implementation** | idea / partial / substantial / complete |
+| **Stability** | unstable / evolving / stable |
+| **Usage** | none / personal / internal / production |
+
+These help an agent decide whether to depend on, extend, or avoid a repo
+without needing to read its full codebase.
+
+---
+
+## Design principles
+
+- **Intentionally short and scannable** — not comprehensive documentation
+- **Pragmatic** — real usage scenarios, not ideals
+- **Easy to maintain** — update when scope changes, not on every commit
+- **Direct language** — no filler, no marketing, no invented features
+- **Honest about gaps** — if something is incomplete or unstable, say so
+
+**Anti-goals:**
+- No long prose or verbose explanations
+- No repetition of README content
+- No hiding ambiguity behind vague language
+- No assumption of production readiness
+
+---
+
+## How SCOPE.md is created
+
+### New repositories
+
+When a repo is registered via `make register-project`, the scaffold copies
+`scope.template` → `SCOPE.md` at the repo root. The human or an agent then
+populates the sections from the repo's actual state.
+
+### Existing repositories
+
+The **scope-analyst** kaizen agent persona can be loaded to generate or refine
+a SCOPE.md:
+
+```
+get_kaizen_agent("scope-analyst")
+```
+
+This agent reads the repo's codebase, existing documentation, and CLAUDE.md
+to produce a SCOPE.md that accurately reflects the current state.
+
+---
+
+## Ecosystem coverage
+
+SCOPE.md files exist across all custodian domains:
+
+| Domain | Repos with SCOPE.md |
+|--------|-------------------|
+| **custodian** | the-custodian, kaizen-agentic, ops-bridge, activity-core |
+| **custodian** (netkingdom) | net-kingdom, key-cape |
+| **railiance** | railiance-apps, railiance-cluster, railiance-enablement, railiance-infra, railiance-platform |
+| **markitect** | markitect_project |
+
+---
+
+## Provided Capabilities section
+
+The `## Provided Capabilities` section uses fenced `capability` blocks that
+are machine-readable and ingested into the state-hub capability catalog:
+
+````markdown
+```capability
+type: infrastructure
+title: Cluster provisioning
+description: Provision k8s clusters and managed instances for any domain.
+keywords: [cluster, k8s, privacy, instance]
+```
+````
+
+| Field | Required | Purpose |
+|-------|----------|---------|
+| **type** | yes | Category: `infrastructure`, `api`, `data`, `security`, `documentation`, `other` |
+| **title** | yes | Short name (unique within domain + type) |
+| **description** | no | What this capability provides |
+| **keywords** | no | Routing hints for auto-matching capability requests |
+
+The ingest script (`make ingest-capabilities-all`) parses these blocks from all
+registered repos and populates the state-hub catalog table. This follows
+ADR-001: **files are the origin of truth, DB is cache/index**.
+
+---
+
+## Relation to capabilities
+
+SCOPE.md is both the **human-readable boundary definition** and the **origin of
+truth for the capability catalog**:
+
+| | SCOPE.md | Capability Catalog (DB) |
+|-|----------|------------------------|
+| **Role** | Origin of truth | Derived index |
+| **Granularity** | Per-repository | Per-domain (aggregated from repo files) |
+| **Purpose** | "What is this repo?" + "What can it provide?" | Routing engine for capability requests |
+| **Updates** | Edit the file, re-ingest | Auto-populated from SCOPE.md |
+| **Readable without hub** | Yes — just open the file | No — requires API |
+
+This means a repo is fully self-describing: you can understand what it provides
+by reading SCOPE.md alone, without any centralized infrastructure.
+
+---
+
+## Relation to other concepts
+
+| Concept | Relationship |
+|---------|-------------|
+| **CLAUDE.md** | Build/test/lint instructions — *how* to work with the repo |
+| **SCOPE.md** | Boundary definition — *what* the repo is and isn't |
+| **Capability Catalog** | Operational routing — *what the domain can provide* on request |
+| **Domain Goals** | Strategic direction — *where the domain is heading* |
+| **Project Charters** | Founding intent — *why the domain exists* (in `canon/projects/`) |
+
+---
+
+*SCOPE.md is the boundary definition layer. It tells you whether you are in the
+right place before you start reading code.*
diff --git a/state-hub/mcp_server/server.py b/state-hub/mcp_server/server.py
index 37fd33a..d1bfa78 100644
--- a/state-hub/mcp_server/server.py
+++ b/state-hub/mcp_server/server.py
@@ -1729,6 +1729,173 @@ def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
return json.dumps(_post(f"/messages/{message_id}/reply", {"from_agent": from_agent, "body": body}), indent=2)
+# ---------------------------------------------------------------------------
+# Capability Catalog & Requests
+# ---------------------------------------------------------------------------
+
+@mcp.tool()
+def register_capability(
+ domain: str,
+ capability_type: str,
+ title: str,
+ description: str | None = None,
+ keywords: list[str] | None = None,
+) -> str:
+ """Register a capability that a domain can provide. Used for routing requests.
+
+ Args:
+ domain: Domain slug (e.g. 'railiance', 'markitect')
+ capability_type: Category (e.g. 'infrastructure', 'api', 'data', 'security', 'documentation')
+ title: Short title for this capability
+ description: Longer description (optional)
+ keywords: List of keywords for routing (e.g. ['cluster', 'k8s', 'privacy'])
+ """
+ entry = _post("/capability-catalog", {
+ "domain": domain,
+ "capability_type": capability_type,
+ "title": title,
+ "description": description,
+ "keywords": keywords or [],
+ })
+ return json.dumps(entry, indent=2)
+
+
+@mcp.tool()
+def list_capabilities(
+ domain: str | None = None,
+ capability_type: str | None = None,
+) -> str:
+ """Browse the capability catalog — what domains can provide.
+
+ Args:
+ domain: Filter by domain slug (optional)
+ capability_type: Filter by type (optional)
+ """
+ return json.dumps(_get("/capability-catalog", {
+ "domain": domain,
+ "capability_type": capability_type,
+ }), indent=2)
+
+
+@mcp.tool()
+def request_capability(
+ title: str,
+ description: str,
+ capability_type: str,
+ requesting_agent: str,
+ requesting_domain: str,
+ requesting_workstream_id: str | None = None,
+ priority: str = "medium",
+ blocking_task_id: str | None = None,
+) -> str:
+ """Request a capability from another domain. Auto-routes to the responsible
+ domain via the capability catalog. If no unique match, broadcasts to all.
+
+ Args:
+ title: Short title (e.g. 'Privacy idea instance on cluster')
+ description: Detailed description of what you need
+ capability_type: Category (e.g. 'infrastructure', 'api', 'data', 'security')
+ requesting_agent: Your agent identifier (e.g. 'net-kingdom-worker')
+ requesting_domain: Your domain slug (e.g. 'custodian')
+ requesting_workstream_id: UUID of your workstream (optional)
+ priority: low | medium | high | critical (default: medium)
+ blocking_task_id: UUID of the task blocked until this is fulfilled (optional)
+ """
+ req = _post("/capability-requests", {
+ "title": title,
+ "description": description,
+ "capability_type": capability_type,
+ "requesting_agent": requesting_agent,
+ "requesting_domain": requesting_domain,
+ "requesting_workstream_id": requesting_workstream_id,
+ "priority": priority,
+ "blocking_task_id": blocking_task_id,
+ })
+ _post("/progress", {
+ "event_type": "capability_requested",
+ "summary": f"Capability requested: {title} ({capability_type})",
+ "author": requesting_agent,
+ "detail": {
+ "capability_request_id": req.get("id"),
+ "capability_type": capability_type,
+ "routed_to": req.get("fulfilling_domain_slug"),
+ },
+ })
+ return json.dumps(req, indent=2)
+
+
+@mcp.tool()
+def accept_capability_request(
+ request_id: str,
+ fulfilling_agent: str,
+ fulfilling_workstream_id: str | None = None,
+) -> str:
+ """Accept a capability request. Assigns yourself as the fulfilling agent.
+
+ Args:
+ request_id: UUID of the capability request
+ fulfilling_agent: Your agent identifier (e.g. 'railiance-worker')
+ fulfilling_workstream_id: UUID of your workstream for this work (optional)
+ """
+ result = _post(f"/capability-requests/{request_id}/accept", {
+ "fulfilling_agent": fulfilling_agent,
+ "fulfilling_workstream_id": fulfilling_workstream_id,
+ })
+ return json.dumps(result, indent=2)
+
+
+@mcp.tool()
+def update_capability_request_status(
+ request_id: str,
+ status: str,
+ note: str | None = None,
+) -> str:
+ """Advance a capability request through its lifecycle.
+
+ On 'completed': auto-unblocks the blocking task if one was set.
+
+ Args:
+ request_id: UUID of the capability request
+ status: in_progress | ready_for_review | completed | rejected | withdrawn
+ note: Optional note (required for rejection, recommended for completion)
+ """
+ result = _patch(f"/capability-requests/{request_id}/status", {
+ "status": status,
+ "note": note,
+ })
+ return json.dumps(result, indent=2)
+
+
+@mcp.tool()
+def list_capability_requests(
+ domain: str | None = None,
+ status: str | None = None,
+ capability_type: str | None = None,
+) -> str:
+ """List capability requests with optional filters.
+
+ Args:
+ domain: Filter by requesting OR fulfilling domain slug
+ status: Filter by status (requested/accepted/in_progress/ready_for_review/completed/rejected/withdrawn)
+ capability_type: Filter by capability type
+ """
+ return json.dumps(_get("/capability-requests", {
+ "domain": domain,
+ "status": status,
+ "capability_type": capability_type,
+ }), indent=2)
+
+
+@mcp.tool()
+def get_capability_request(request_id: str) -> str:
+ """Get a single capability request by ID.
+
+ Args:
+ request_id: UUID of the capability request
+ """
+ return json.dumps(_get(f"/capability-requests/{request_id}"), indent=2)
+
+
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
diff --git a/state-hub/migrations/versions/i6d7e8f9a0b1_capability_requests.py b/state-hub/migrations/versions/i6d7e8f9a0b1_capability_requests.py
new file mode 100644
index 0000000..968a9d7
--- /dev/null
+++ b/state-hub/migrations/versions/i6d7e8f9a0b1_capability_requests.py
@@ -0,0 +1,74 @@
+"""add capability_catalog and capability_requests tables
+
+Revision ID: i6d7e8f9a0b1
+Revises: h5c6d7e8f9a0
+Create Date: 2026-03-19
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision = "i6d7e8f9a0b1"
+down_revision = "h5c6d7e8f9a0"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "capability_catalog",
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True,
+ server_default=sa.text("gen_random_uuid()")),
+ sa.Column("domain_id", postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("domains.id", ondelete="RESTRICT"),
+ nullable=False, index=True),
+ sa.Column("capability_type", sa.String(50), nullable=False),
+ sa.Column("title", sa.String(255), nullable=False),
+ sa.Column("description", sa.Text, nullable=True),
+ sa.Column("keywords", sa.ARRAY(sa.String), nullable=False, server_default="{}"),
+ sa.Column("status", sa.String(20), nullable=False, server_default="active"),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
+ )
+
+ op.create_table(
+ "capability_requests",
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True,
+ server_default=sa.text("gen_random_uuid()")),
+ sa.Column("title", sa.String(500), nullable=False),
+ sa.Column("description", sa.Text, nullable=True),
+ sa.Column("capability_type", sa.String(50), nullable=False),
+ sa.Column("priority", sa.String(20), nullable=False, server_default="medium"),
+ sa.Column("status", sa.String(20), nullable=False, server_default="requested"),
+ sa.Column("requesting_domain_id", postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("domains.id", ondelete="RESTRICT"),
+ nullable=False, index=True),
+ sa.Column("requesting_workstream_id", postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("workstreams.id", ondelete="SET NULL"),
+ nullable=True),
+ sa.Column("requesting_agent", sa.String(100), nullable=False),
+ sa.Column("fulfilling_domain_id", postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("domains.id", ondelete="SET NULL"),
+ nullable=True, index=True),
+ sa.Column("fulfilling_workstream_id", postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("workstreams.id", ondelete="SET NULL"),
+ nullable=True),
+ sa.Column("fulfilling_agent", sa.String(100), nullable=True),
+ sa.Column("blocking_task_id", postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("tasks.id", ondelete="SET NULL"),
+ nullable=True),
+ sa.Column("catalog_entry_id", postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("capability_catalog.id", ondelete="SET NULL"),
+ nullable=True),
+ sa.Column("resolution_note", sa.Text, nullable=True),
+ sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("capability_requests")
+ op.drop_table("capability_catalog")
diff --git a/state-hub/scripts/ingest_capabilities.py b/state-hub/scripts/ingest_capabilities.py
new file mode 100644
index 0000000..4a4d0da
--- /dev/null
+++ b/state-hub/scripts/ingest_capabilities.py
@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+"""Ingest capability declarations from SCOPE.md files into the State Hub catalog.
+
+Reads ``## Provided Capabilities`` sections from SCOPE.md files in registered
+repos and upserts them into the capability_catalog table via the API.
+
+Usage:
+ python ingest_capabilities.py --repo [--repo-path ] [--dry-run]
+ python ingest_capabilities.py --all [--dry-run]
+
+Capability blocks in SCOPE.md use this format:
+
+ ```capability
+ type: infrastructure
+ title: Cluster provisioning
+ description: Provision k8s clusters for any domain.
+ keywords: [cluster, k8s, privacy, instance]
+ ```
+
+Follows ADR-001: SCOPE.md files are the origin of truth; the DB catalog is
+a derived index that can be fully reconstructed from repo files.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import socket
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+try:
+ import yaml
+ _YAML_AVAILABLE = True
+except ImportError:
+ _YAML_AVAILABLE = False
+
+API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
+
+# ---------------------------------------------------------------------------
+# SCOPE.md parser
+# ---------------------------------------------------------------------------
+
+_CAPABILITY_BLOCK_RE = re.compile(r"```capability\s*\n(.*?)\n```", re.DOTALL)
+
+
+def parse_capabilities(scope_path: Path) -> list[dict]:
+ """Extract capability blocks from a SCOPE.md file."""
+ if not scope_path.exists():
+ return []
+ text = scope_path.read_text()
+ blocks = _CAPABILITY_BLOCK_RE.findall(text)
+ capabilities = []
+ for block in blocks:
+ cap = _parse_yaml_block(block)
+ if cap.get("type") and cap.get("title"):
+ capabilities.append({
+ "capability_type": cap["type"],
+ "title": cap["title"],
+ "description": cap.get("description", ""),
+ "keywords": cap.get("keywords", []),
+ })
+ return capabilities
+
+
+def _parse_yaml_block(text: str) -> dict:
+ """Parse a YAML-like key: value block. Uses PyYAML if available, falls back to manual."""
+ if _YAML_AVAILABLE:
+ try:
+ result = yaml.safe_load(text)
+ if isinstance(result, dict):
+ return result
+ except yaml.YAMLError:
+ pass
+ # Fallback: manual key: value parsing
+ result = {}
+ for line in text.strip().splitlines():
+ line = line.strip()
+ if not line or ":" not in line:
+ continue
+ key, _, val = line.partition(":")
+ key = key.strip()
+ val = val.strip()
+ if val.startswith("[") and val.endswith("]"):
+ # Parse simple list: [a, b, c]
+ inner = val[1:-1]
+ result[key] = [v.strip().strip("'\"") for v in inner.split(",") if v.strip()]
+ else:
+ result[key] = val.strip("'\"")
+ return result
+
+
+# ---------------------------------------------------------------------------
+# API helpers
+# ---------------------------------------------------------------------------
+
+def _api_get(path: str) -> dict | list | None:
+ url = f"{API_BASE}{path}"
+ # Add trailing slash before query params for FastAPI redirect avoidance
+ if "?" in url:
+ base, qs = url.split("?", 1)
+ if not base.endswith("/"):
+ base += "/"
+ url = f"{base}?{qs}"
+ elif not url.endswith("/"):
+ url += "/"
+ try:
+ req = urllib.request.Request(url)
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ return json.loads(resp.read())
+ except Exception as e:
+ print(f" GET {url} failed: {e}", file=sys.stderr)
+ return None
+
+
+def _api_post(path: str, body: dict) -> dict | None:
+ url = f"{API_BASE}{path}"
+ if not url.endswith("/"):
+ url += "/"
+ data = json.dumps({k: v for k, v in body.items() if v is not None}).encode()
+ try:
+ req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ return json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ body_text = e.read().decode()[:200]
+ print(f" POST {url} → {e.code}: {body_text}", file=sys.stderr)
+ return None
+ except Exception as e:
+ print(f" POST {url} failed: {e}", file=sys.stderr)
+ return None
+
+
+def resolve_repo_path(repo: dict, override: str | None = None) -> str:
+ if override:
+ return override
+ hostname = socket.gethostname()
+ host_paths = repo.get("host_paths") or {}
+ return host_paths.get(hostname) or repo.get("local_path") or ""
+
+
+# ---------------------------------------------------------------------------
+# Ingest logic
+# ---------------------------------------------------------------------------
+
+def ingest_repo(repo_slug: str, repo_path_override: str | None = None, dry_run: bool = False) -> int:
+ """Ingest capabilities from one repo's SCOPE.md. Returns count of capabilities found."""
+ repo = _api_get(f"/repos/{repo_slug}")
+ if repo is None:
+ print(f" Repo '{repo_slug}' not found in state-hub", file=sys.stderr)
+ return 0
+
+ repo_path = resolve_repo_path(repo, repo_path_override)
+ if not repo_path:
+ print(f" Repo '{repo_slug}' has no local path on this host", file=sys.stderr)
+ return 0
+
+ scope_path = Path(repo_path) / "SCOPE.md"
+ if not scope_path.exists():
+ print(f" {repo_slug}: no SCOPE.md at {scope_path}")
+ return 0
+
+ capabilities = parse_capabilities(scope_path)
+ if not capabilities:
+ print(f" {repo_slug}: no capability blocks in SCOPE.md")
+ return 0
+
+ # Resolve domain slug for this repo
+ domain_slug = repo.get("domain_slug")
+ if not domain_slug:
+ # Fetch domain from repo's domain_id
+ domains = _api_get("/domains/") or []
+ domain_map = {d["id"]: d["slug"] for d in domains if isinstance(d, dict)}
+ domain_slug = domain_map.get(repo.get("domain_id"), "")
+ if not domain_slug:
+ print(f" {repo_slug}: cannot resolve domain slug", file=sys.stderr)
+ return 0
+
+ # Get existing catalog entries for this domain to avoid duplicates
+ existing = _api_get(f"/capability-catalog/?domain={domain_slug}&status=all") or []
+ existing_keys = {(e["capability_type"], e["title"]) for e in existing if isinstance(e, dict)}
+
+ count = 0
+ for cap in capabilities:
+ key = (cap["capability_type"], cap["title"])
+ if key in existing_keys:
+ print(f" {repo_slug}: skip (exists) {cap['capability_type']}/{cap['title']}")
+ continue
+
+ if dry_run:
+ print(f" {repo_slug}: [dry-run] would create {cap['capability_type']}/{cap['title']}")
+ else:
+ result = _api_post("/capability-catalog", {
+ "domain": domain_slug,
+ "capability_type": cap["capability_type"],
+ "title": cap["title"],
+ "description": cap["description"],
+ "keywords": cap["keywords"],
+ })
+ if result:
+ print(f" {repo_slug}: created {cap['capability_type']}/{cap['title']} → {result.get('id', '?')[:8]}")
+ else:
+ print(f" {repo_slug}: FAILED to create {cap['capability_type']}/{cap['title']}")
+ count += 1
+
+ return count
+
+
+def ingest_all(dry_run: bool = False) -> None:
+ """Ingest capabilities from all registered repos."""
+ repos = _api_get("/repos/") or []
+ total = 0
+ for repo in repos:
+ slug = repo.get("slug", "")
+ if not slug:
+ continue
+ print(f"\n[{slug}]")
+ total += ingest_repo(slug, dry_run=dry_run)
+ print(f"\nDone. {total} capability entries {'would be ' if dry_run else ''}ingested.")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def main():
+ parser = argparse.ArgumentParser(description="Ingest capabilities from SCOPE.md into state-hub catalog")
+ parser.add_argument("--repo", help="Repo slug to ingest")
+ parser.add_argument("--repo-path", help="Override repo filesystem path")
+ parser.add_argument("--all", action="store_true", help="Ingest from all registered repos")
+ parser.add_argument("--dry-run", action="store_true", help="Print what would be ingested without writing")
+ parser.add_argument("--api-base", help="Override API base URL")
+ args = parser.parse_args()
+
+ if args.api_base:
+ global API_BASE
+ API_BASE = args.api_base.rstrip("/")
+
+ if args.all:
+ ingest_all(dry_run=args.dry_run)
+ elif args.repo:
+ print(f"[{args.repo}]")
+ count = ingest_repo(args.repo, repo_path_override=args.repo_path, dry_run=args.dry_run)
+ print(f"\nDone. {count} capability entries {'would be ' if args.dry_run else ''}ingested.")
+ else:
+ parser.error("Specify --repo or --all")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/state-hub/scripts/project_rules/scope.template b/state-hub/scripts/project_rules/scope.template
index 605b284..d48e39a 100644
--- a/state-hub/scripts/project_rules/scope.template
+++ b/state-hub/scripts/project_rules/scope.template
@@ -115,6 +115,23 @@
---
+## Provided Capabilities
+
+
+
+
+
+
+
+---
+
## Notes
diff --git a/state-hub/tests/test_capability_requests.py b/state-hub/tests/test_capability_requests.py
new file mode 100644
index 0000000..a908b87
--- /dev/null
+++ b/state-hub/tests/test_capability_requests.py
@@ -0,0 +1,337 @@
+"""
+Capability Request system tests: catalog CRUD, request lifecycle, routing,
+auto-notifications, and task unblocking.
+
+All tests use a real PostgreSQL test database (no mocking).
+"""
+from __future__ import annotations
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+async def _create_domain(client, slug="testdomain", name="Test Domain"):
+ r = await client.post("/domains/", json={"slug": slug, "name": name})
+ assert r.status_code == 201, r.text
+ return r.json()
+
+
+async def _create_topic(client, domain_slug="testdomain"):
+ r = await client.post("/topics/", json={
+ "slug": "testtopic", "title": "Test Topic", "domain": domain_slug,
+ })
+ assert r.status_code == 201, r.text
+ return r.json()
+
+
+async def _create_workstream(client, topic_id):
+ r = await client.post("/workstreams/", json={
+ "topic_id": topic_id, "slug": "test-ws", "title": "Test WS",
+ })
+ assert r.status_code == 201, r.text
+ return r.json()
+
+
+async def _create_task(client, workstream_id, title="Test task", status="blocked"):
+ r = await client.post("/tasks/", json={
+ "workstream_id": workstream_id, "title": title,
+ })
+ assert r.status_code == 201, r.text
+ task = r.json()
+ if status != "todo":
+ patch = {"status": status}
+ if status == "blocked":
+ patch["blocking_reason"] = "Waiting for capability request"
+ r2 = await client.patch(f"/tasks/{task['id']}", json=patch)
+ assert r2.status_code == 200, r2.text
+ return r2.json()
+ return task
+
+
+async def _setup_two_domains(client):
+ """Create two domains: 'custodian' (requester) and 'railiance' (provider)."""
+ req_domain = await _create_domain(client, "custodian", "Custodian")
+ ful_domain = await _create_domain(client, "railiance", "Railiance")
+ return req_domain, ful_domain
+
+
+async def _register_catalog(client, domain="railiance", cap_type="infrastructure",
+ title="Cluster provisioning", keywords=None):
+ r = await client.post("/capability-catalog/", json={
+ "domain": domain,
+ "capability_type": cap_type,
+ "title": title,
+ "keywords": keywords or ["cluster", "k8s", "privacy"],
+ })
+ assert r.status_code == 201, r.text
+ return r.json()
+
+
+async def _create_request(client, title="Privacy idea instance",
+ description="Need a privacy idea instance on the cluster",
+ cap_type="infrastructure", agent="net-kingdom-worker",
+ domain="custodian", **kwargs):
+ r = await client.post("/capability-requests/", json={
+ "title": title,
+ "description": description,
+ "capability_type": cap_type,
+ "requesting_agent": agent,
+ "requesting_domain": domain,
+ **kwargs,
+ })
+ assert r.status_code == 201, r.text
+ return r.json()
+
+
+# ---------------------------------------------------------------------------
+# Catalog tests
+# ---------------------------------------------------------------------------
+
+class TestCapabilityCatalog:
+ async def test_register_and_list(self, client):
+ await _setup_two_domains(client)
+ entry = await _register_catalog(client)
+ assert entry["capability_type"] == "infrastructure"
+ assert entry["domain_slug"] == "railiance"
+
+ r = await client.get("/capability-catalog/")
+ assert r.status_code == 200
+ assert len(r.json()) == 1
+
+ async def test_duplicate_entry_rejected(self, client):
+ await _setup_two_domains(client)
+ await _register_catalog(client)
+ r = await client.post("/capability-catalog/", json={
+ "domain": "railiance",
+ "capability_type": "infrastructure",
+ "title": "Cluster provisioning",
+ "keywords": [],
+ })
+ assert r.status_code == 409
+
+ async def test_filter_by_domain_and_type(self, client):
+ await _setup_two_domains(client)
+ await _register_catalog(client, domain="railiance", cap_type="infrastructure")
+ await _register_catalog(client, domain="railiance", cap_type="security",
+ title="TLS cert provisioning", keywords=["tls", "cert"])
+ await _register_catalog(client, domain="custodian", cap_type="api",
+ title="MCP tool registration", keywords=["mcp"])
+
+ # Filter by domain
+ r = await client.get("/capability-catalog/", params={"domain": "railiance"})
+ assert len(r.json()) == 2
+
+ # Filter by type
+ r = await client.get("/capability-catalog/", params={"capability_type": "api"})
+ assert len(r.json()) == 1
+ assert r.json()[0]["domain_slug"] == "custodian"
+
+
+# ---------------------------------------------------------------------------
+# Request lifecycle tests
+# ---------------------------------------------------------------------------
+
+class TestCapabilityRequestLifecycle:
+ async def test_create_auto_routes_single_match(self, client):
+ await _setup_two_domains(client)
+ await _register_catalog(client)
+ req = await _create_request(client)
+
+ assert req["status"] == "requested"
+ assert req["fulfilling_domain_slug"] == "railiance"
+ assert req["catalog_entry_id"] is not None
+
+ async def test_create_broadcasts_when_no_catalog(self, client):
+ await _setup_two_domains(client)
+ req = await _create_request(client, cap_type="documentation")
+
+ assert req["status"] == "requested"
+ assert req["fulfilling_domain_slug"] is None
+ assert req["catalog_entry_id"] is None
+
+ async def test_create_broadcasts_when_ambiguous(self, client):
+ await _setup_two_domains(client)
+ # Two entries for same type, equal keyword scores
+ await _register_catalog(client, domain="railiance", cap_type="infrastructure",
+ title="K8s clusters", keywords=["k8s"])
+ await _register_catalog(client, domain="custodian", cap_type="infrastructure",
+ title="Local infra", keywords=["local"])
+
+ req = await _create_request(client, description="Need something generic")
+ # Neither keyword matches, should broadcast
+ assert req["fulfilling_domain_slug"] is None
+
+ async def test_valid_transitions(self, client):
+ await _setup_two_domains(client)
+ req = await _create_request(client)
+
+ # accepted
+ r = await client.post(f"/capability-requests/{req['id']}/accept", json={
+ "fulfilling_agent": "railiance-worker",
+ })
+ assert r.status_code == 200
+ assert r.json()["status"] == "accepted"
+ assert r.json()["accepted_at"] is not None
+
+ # in_progress
+ r = await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "in_progress",
+ })
+ assert r.status_code == 200
+ assert r.json()["status"] == "in_progress"
+
+ # ready_for_review
+ r = await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "ready_for_review", "note": "Privacy instance is up",
+ })
+ assert r.status_code == 200
+ assert r.json()["status"] == "ready_for_review"
+
+ # completed
+ r = await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "completed", "note": "Verified and running",
+ })
+ assert r.status_code == 200
+ assert r.json()["status"] == "completed"
+ assert r.json()["completed_at"] is not None
+
+ async def test_invalid_transitions_422(self, client):
+ await _setup_two_domains(client)
+ req = await _create_request(client)
+
+ # requested → in_progress (must accept first)
+ r = await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "in_progress",
+ })
+ assert r.status_code == 422
+
+ # requested → completed (skip steps)
+ r = await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "completed",
+ })
+ assert r.status_code == 422
+
+ async def test_accept_sets_fulfilling_fields(self, client):
+ await _setup_two_domains(client)
+ req = await _create_request(client)
+
+ r = await client.post(f"/capability-requests/{req['id']}/accept", json={
+ "fulfilling_agent": "railiance-worker",
+ })
+ data = r.json()
+ assert data["fulfilling_agent"] == "railiance-worker"
+ assert data["accepted_at"] is not None
+
+ async def test_complete_unblocks_blocking_task(self, client):
+ await _setup_two_domains(client)
+ topic = await _create_topic(client, "custodian")
+ ws = await _create_workstream(client, topic["id"])
+ task = await _create_task(client, ws["id"], status="blocked")
+
+ req = await _create_request(client, blocking_task_id=task["id"])
+
+ # Walk through lifecycle
+ await client.post(f"/capability-requests/{req['id']}/accept", json={
+ "fulfilling_agent": "railiance-worker",
+ })
+ await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "in_progress",
+ })
+ await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "ready_for_review",
+ })
+ r = await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "completed",
+ })
+ assert r.status_code == 200
+
+ # Verify task was unblocked
+ r = await client.get(f"/tasks/{task['id']}")
+ assert r.status_code == 200
+ assert r.json()["status"] == "todo"
+
+ async def test_complete_without_blocking_task(self, client):
+ await _setup_two_domains(client)
+ req = await _create_request(client)
+
+ await client.post(f"/capability-requests/{req['id']}/accept", json={
+ "fulfilling_agent": "railiance-worker",
+ })
+ await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "in_progress",
+ })
+ await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "ready_for_review",
+ })
+ r = await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "completed",
+ })
+ # Should succeed without error (no task to unblock)
+ assert r.status_code == 200
+ assert r.json()["status"] == "completed"
+
+ async def test_notification_created_on_each_transition(self, client):
+ await _setup_two_domains(client)
+ await _register_catalog(client)
+ req = await _create_request(client)
+
+ # Check notification was sent on creation (to railiance since it was auto-routed)
+ r = await client.get("/messages/", params={"to_agent": "railiance"})
+ msgs = r.json()
+ assert any("[capability-request]" in m["subject"] for m in msgs)
+
+ # Accept
+ await client.post(f"/capability-requests/{req['id']}/accept", json={
+ "fulfilling_agent": "railiance-worker",
+ })
+ r = await client.get("/messages/", params={"to_agent": "net-kingdom-worker"})
+ msgs = r.json()
+ assert any("[capability-accepted]" in m["subject"] for m in msgs)
+
+ # ready_for_review
+ await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "in_progress",
+ })
+ await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "ready_for_review",
+ })
+ r = await client.get("/messages/", params={"to_agent": "net-kingdom-worker"})
+ msgs = r.json()
+ assert any("[capability-ready]" in m["subject"] for m in msgs)
+
+ async def test_list_filters_by_domain_status_type(self, client):
+ await _setup_two_domains(client)
+ await _create_request(client, title="Req A")
+ await _create_request(client, title="Req B", cap_type="security")
+
+ # Filter by type
+ r = await client.get("/capability-requests/", params={"capability_type": "security"})
+ assert len(r.json()) == 1
+ assert r.json()[0]["title"] == "Req B"
+
+ # Filter by status
+ r = await client.get("/capability-requests/", params={"status": "requested"})
+ assert len(r.json()) == 2
+
+ # Filter by domain
+ r = await client.get("/capability-requests/", params={"domain": "custodian"})
+ assert len(r.json()) == 2 # requesting domain is custodian for both
+
+ async def test_withdrawn_transition(self, client):
+ await _setup_two_domains(client)
+ req = await _create_request(client)
+
+ r = await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "withdrawn", "note": "No longer needed",
+ })
+ assert r.status_code == 200
+ assert r.json()["status"] == "withdrawn"
+
+ # Terminal — cannot transition further
+ r = await client.patch(f"/capability-requests/{req['id']}/status", json={
+ "status": "requested",
+ })
+ assert r.status_code == 422
diff --git a/workplans/CUST-WP-0022-capability-requests.md b/workplans/CUST-WP-0022-capability-requests.md
new file mode 100644
index 0000000..33836c2
--- /dev/null
+++ b/workplans/CUST-WP-0022-capability-requests.md
@@ -0,0 +1,146 @@
+---
+id: CUST-WP-0022
+type: workplan
+title: Capability Request System
+domain: custodian
+status: done
+owner: custodian
+topic_slug: the-custodian
+created: "2026-03-19"
+updated: "2026-03-19"
+state_hub_workstream_id: "7cc173c6-f63e-43b0-af8e-04df7f95b96c"
+---
+
+# CUST-WP-0022 — Capability Request System
+
+## Purpose
+
+Enable cross-domain capability requests without requiring the requester to know
+which domain is responsible. A net-kingdom dev-worker can say "I need a privacy
+idea instance on the cluster" and the system routes it to railiance, manages the
+fulfillment lifecycle, and auto-notifies when work is ready for review.
+
+## Scope
+
+- `capability_catalog` table — domains register capabilities they can provide
+- `capability_requests` table — lifecycle from requested → completed with auto-routing
+- Soft routing via keyword matching; broadcast fallback when ambiguous
+- Auto-notifications via AgentMessage on every lifecycle transition
+- Auto-unblock of blocking tasks on completion
+- 7 MCP tools, 1 dashboard page, StateSummary integration
+
+## Files Changed
+
+| File | Change |
+|------|--------|
+| `migrations/versions/i6d7e8f9a0b1_capability_requests.py` | New migration |
+| `api/models/capability_catalog.py` | New model |
+| `api/models/capability_request.py` | New model |
+| `api/models/__init__.py` | Export new models |
+| `api/schemas/capability_request.py` | New schemas (Catalog + Request CRUD) |
+| `api/routers/capability_requests.py` | New router (lifecycle guard, routing, auto-notify) |
+| `api/main.py` | Mount new router |
+| `api/schemas/state.py` | Added `open_capability_requests` to StateSummary |
+| `api/routers/state.py` | Count open requests in summary builder |
+| `mcp_server/server.py` | 7 new MCP tools |
+| `dashboard/src/capability-requests.md` | New dashboard page (Kanban + KPIs) |
+| `dashboard/observablehq.config.js` | Nav entry |
+| `tests/test_capability_requests.py` | 14 test cases |
+| `scripts/ingest_capabilities.py` | Ingest `capability` blocks from SCOPE.md → catalog API |
+| `scripts/project_rules/scope.template` | Added `## Provided Capabilities` section |
+| `SCOPE.md` (repo root) | Added 3 capability blocks for custodian domain |
+| `dashboard/src/docs/capabilities.md` | Reference doc for capabilities |
+| `dashboard/src/docs/scope.md` | Reference doc for SCOPE.md |
+
+## Tasks
+
+### T01: Write TDD test suite
+```task
+id: CUST-WP-0022-T01
+status: done
+priority: high
+state_hub_task_id: "34f40ede-3396-4ef2-ae5a-9af4edbfc17d"
+```
+
+### T02: Alembic migration
+```task
+id: CUST-WP-0022-T02
+status: done
+priority: high
+state_hub_task_id: "094fd9de-e4fa-4b9f-bc70-31f049907fa9"
+```
+
+### T03: SQLAlchemy models
+```task
+id: CUST-WP-0022-T03
+status: done
+priority: high
+state_hub_task_id: "5fadabfb-100e-493e-a950-2b5c3d286c6b"
+```
+
+### T04: Pydantic schemas
+```task
+id: CUST-WP-0022-T04
+status: done
+priority: high
+state_hub_task_id: "1efabfde-e9de-41d9-a864-2eb1e9e2dae6"
+```
+
+### T05: Router with lifecycle + routing + auto-notify
+```task
+id: CUST-WP-0022-T05
+status: done
+priority: high
+state_hub_task_id: "57f754b6-022c-4829-adb5-45629567d575"
+```
+
+### T06: Run tests, iterate until green
+```task
+id: CUST-WP-0022-T06
+status: done
+priority: high
+state_hub_task_id: "41ef3722-ffbe-41bb-9ef4-8485db37bb0a"
+```
+
+### T07: MCP tools
+```task
+id: CUST-WP-0022-T07
+status: done
+priority: medium
+state_hub_task_id: "12f28272-2767-4d8e-80a0-a28bb172d6af"
+```
+
+### T08: Dashboard page
+```task
+id: CUST-WP-0022-T08
+status: done
+priority: medium
+state_hub_task_id: "d6596ac4-53ac-4b44-bdd2-635b5c109372"
+```
+
+### T09: StateSummary integration
+```task
+id: CUST-WP-0022-T09
+status: done
+priority: medium
+state_hub_task_id: "964e7f8b-e9f6-41f9-9eb1-ba35e48adf55"
+```
+
+### T10: Seed catalog entries (file-first via SCOPE.md)
+```task
+id: CUST-WP-0022-T10
+status: done
+priority: low
+state_hub_task_id: "2c747cde-bc00-4fd9-a3cd-625709ac6d6d"
+```
+
+## Design Notes
+
+- **Routing algorithm**: Exact match on capability_type in catalog. If 1 result → auto-assign.
+ If multiple → keyword-score description against entry keywords, pick unambiguous winner.
+ If 0 or tied → fulfilling_domain_id=NULL, broadcast AgentMessage.
+- **Lifecycle transitions** guarded by `_VALID_TRANSITIONS` dict (same pattern as contributions).
+- **Auto-unblock**: On `completed`, if `blocking_task_id` set and task status is `blocked`,
+ patches it to `todo` and clears `blocking_reason`.
+- **Status uses String(20)** not SA Enum — avoids ALTER TYPE pain in future migrations.
+- **Third sanctioned write use case** — alongside resolve_decision and get_next_steps.