feat(capability-requests): add cross-domain capability catalog and request routing

Introduces a capability catalog (CUST-WP-0022) so domains can advertise what
they provide and agents can request capabilities from other domains with
auto-routing, lifecycle tracking, and task-unblocking on completion.

- New models: CapabilityCatalog, CapabilityRequest with full lifecycle
  (requested → accepted → in_progress → ready_for_review → completed/rejected/withdrawn)
- Migration i6d7e8f9a0b1: capability_catalog + capability_requests tables
- Router /capability-catalog and /capability-requests with accept/status endpoints
- 7 new MCP tools: register_capability, list_capabilities, request_capability,
  accept_capability_request, update_capability_request_status,
  list_capability_requests, get_capability_request
- StateSummary gains open_capability_requests count
- Dashboard: capability-requests.md page + docs/capabilities.md + docs/scope.md
- SCOPE.md: three seed capabilities documented (MCP registration, state tracking, SBOM)
- scope.template: Provided Capabilities section with example block
- scripts/ingest_capabilities.py + make ingest-capabilities[/-all] targets

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:07:50 +01:00
parent 7bf3cf583a
commit d45234531b
18 changed files with 2105 additions and 1 deletions

View File

@@ -169,6 +169,20 @@ ingest-sbom:
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
$(if $(DRY_RUN),--dry-run) $(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. ## 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] ## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra]
## Add DRY_RUN=1 to preview without writing. ## Add DRY_RUN=1 to preview without writing.

View File

@@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from api.database import engine 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 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 @asynccontextmanager
@@ -47,6 +47,7 @@ app.include_router(repo_goals.router)
app.include_router(contributions.router) app.include_router(contributions.router)
app.include_router(sbom.router) app.include_router(sbom.router)
app.include_router(messages.router) app.include_router(messages.router)
app.include_router(capability_requests.router)
app.include_router(state.router) app.include_router(state.router)
app.include_router(policy.router) app.include_router(policy.router)

View File

@@ -15,6 +15,8 @@ from api.models.contribution import Contribution, ContributionType, Contribution
from api.models.sbom_snapshot import SBOMSnapshot from api.models.sbom_snapshot import SBOMSnapshot
from api.models.sbom_entry import SBOMEntry, Ecosystem from api.models.sbom_entry import SBOMEntry, Ecosystem
from api.models.agent_message import AgentMessage from api.models.agent_message import AgentMessage
from api.models.capability_catalog import CapabilityCatalog
from api.models.capability_request import CapabilityRequest
__all__ = [ __all__ = [
"Base", "Base",
@@ -34,4 +36,6 @@ __all__ = [
"SBOMSnapshot", "SBOMSnapshot",
"SBOMEntry", "Ecosystem", "SBOMEntry", "Ecosystem",
"AgentMessage", "AgentMessage",
"CapabilityCatalog",
"CapabilityRequest",
] ]

View File

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

View File

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

View File

@@ -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)'}"
),
)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session, engine 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.contribution import Contribution, ContributionStatus, ContributionType
from api.models.decision import Decision, DecisionStatus, DecisionType from api.models.decision import Decision, DecisionStatus, DecisionType
from api.models.domain import Domain 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) 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( return StateSummary(
generated_at=datetime.now(tz=timezone.utc), generated_at=datetime.now(tz=timezone.utc),
totals=totals, totals=totals,
@@ -215,6 +223,7 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
domains=domain_summaries, domains=domain_summaries,
contribution_counts=contribution_counts, contribution_counts=contribution_counts,
licence_risk_count=licence_risk_count, licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_count,
open_workstreams=[ open_workstreams=[
WorkstreamWithDeps( WorkstreamWithDeps(
**WorkstreamRead.model_validate(w).model_dump(), **WorkstreamRead.model_validate(w).model_dump(),

View File

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

View File

@@ -79,3 +79,4 @@ class StateSummary(BaseModel):
domains: list[DomainSummary] = [] domains: list[DomainSummary] = []
contribution_counts: dict[str, int] = {} contribution_counts: dict[str, int] = {}
licence_risk_count: int = 0 licence_risk_count: int = 0
open_capability_requests: int = 0

View File

@@ -19,6 +19,7 @@ export default {
pages: [ pages: [
// ── Pages (Overview first, then alphabetical) ──────────────────────────── // ── Pages (Overview first, then alphabetical) ────────────────────────────
{ name: "Overview", path: "/" }, { name: "Overview", path: "/" },
{ name: "Capabilities", path: "/capability-requests" },
{ name: "Contributions", path: "/contributions" }, { name: "Contributions", path: "/contributions" },
{ name: "Domains", path: "/domains" }, { name: "Domains", path: "/domains" },
{ name: "Goals", path: "/goals" }, { name: "Goals", path: "/goals" },
@@ -66,6 +67,7 @@ export default {
collapsible: true, collapsible: true,
open: false, open: false,
pages: [ pages: [
{ name: "Capabilities", path: "/docs/capabilities" },
{ name: "Connecting to the Hub", path: "/docs/connecting" }, { name: "Connecting to the Hub", path: "/docs/connecting" },
{ name: "Contributions", path: "/docs/contributions" }, { name: "Contributions", path: "/docs/contributions" },
{ name: "Decision Health", path: "/docs/decisions-kpi" }, { name: "Decision Health", path: "/docs/decisions-kpi" },
@@ -84,6 +86,7 @@ export default {
{ name: "Repo Integration", path: "/docs/repo-integration" }, { name: "Repo Integration", path: "/docs/repo-integration" },
{ name: "Repos", path: "/docs/repos" }, { name: "Repos", path: "/docs/repos" },
{ name: "SBOM", path: "/docs/sbom" }, { name: "SBOM", path: "/docs/sbom" },
{ name: "SCOPE.md", path: "/docs/scope" },
{ name: "Tasks", path: "/docs/tasks" }, { name: "Tasks", path: "/docs/tasks" },
{ name: "Technical Debt", path: "/docs/debt" }, { name: "Technical Debt", path: "/docs/debt" },
{ name: "Todo", path: "/docs/todo" }, { name: "Todo", path: "/docs/todo" },

View File

@@ -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`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">API offline</span>`}
</div>`;
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`<div class="kpi-infobox">
<div class="kpi-infobox-title">Capability Requests</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.8rem;font-size:0.82rem">
<span>Open</span><strong>${open.length}</strong>
<span>Avg fulfill</span><strong>${avgFulfill}d</strong>
<span>High/Critical</span><strong style="color:${critical > 0 ? 'orange' : 'inherit'}">${critical}</strong>
<span>Total</span><strong>${requests.length}</strong>
</div>
</div>`;
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`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
${typeFilter}${statFilter}${domFilter}
</div>`);
```
```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`<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:1.5rem">
<div class="card"><h3>Requested</h3><p class="big-num">${requests.filter(r => r.status === "requested").length}</p></div>
<div class="card"><h3>In Progress</h3><p class="big-num">${requests.filter(r => ["accepted","in_progress"].includes(r.status)).length}</p></div>
<div class="card"><h3>Ready for Review</h3><p class="big-num">${requests.filter(r => r.status === "ready_for_review").length}</p></div>
<div class="card"><h3>Completed</h3><p class="big-num">${completed.length}</p></div>
</div>`);
```
## 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`<p style="color:gray">No capability requests match the current filters.</p>`);
} else {
const ageDays = (r) => ((Date.now() - new Date(r.created_at)) / 86400000).toFixed(0);
display(html`<div class="kanban">
${activeCols.map(s => html`
<div class="kanban-col">
<div class="kanban-header" style="border-bottom:2px solid ${s.color}">${s.label} <span class="kanban-count">${colMap[s.key].length}</span></div>
${colMap[s.key].map(r => html`
<div class="cap-card">
<div class="cap-type-badge" style="background:${priorityColors[r.priority] ?? '#aaa'}20;color:${priorityColors[r.priority] ?? '#aaa'}">${r.capability_type}</div>
<div class="cap-priority-badge" style="color:${priorityColors[r.priority] ?? '#888'}">${r.priority}</div>
<div class="cap-title">${r.title}</div>
<div class="cap-domains">
<span>${r.requesting_domain_slug}</span>
${r.fulfilling_domain_slug ? html` → <strong>${r.fulfilling_domain_slug}</strong>` : html` → <em>unassigned</em>`}
</div>
<div class="cap-age">${ageDays(r)}d old</div>
</div>
`)}
</div>
`)}
</div>`);
}
```
## 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`<p style="color:gray">No capabilities registered yet. Add <code>&#96;&#96;&#96;capability</code> blocks to SCOPE.md files and run <code>make ingest-capabilities-all</code>.</p>`);
} 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`<div class="catalog-grid">
${Object.entries(byDomain).sort((a, b) => a[0].localeCompare(b[0])).map(([domain, caps]) => html`
<div class="catalog-domain">
<div class="catalog-domain-header">${domain} <span class="kanban-count">${caps.length}</span></div>
${caps.map(c => html`
<div class="catalog-entry ${c.status === 'deprecated' ? 'catalog-deprecated' : ''}">
<div class="cap-type-badge" style="background:${(typeColors[c.capability_type] ?? '#888')}18;color:${typeColors[c.capability_type] ?? '#888'}">${c.capability_type}</div>
<div class="catalog-title">${c.title}</div>
${c.description ? html`<div class="catalog-desc">${c.description}</div>` : ""}
${c.keywords?.length ? html`<div class="catalog-kw">${c.keywords.map(k => html`<span class="kw-tag">${k}</span>`)}</div>` : ""}
</div>
`)}
</div>
`)}
</div>`);
}
```
<style>
.live-indicator { font-size: 0.8rem; color: gray; padding: 0.55rem 0.7rem; margin-bottom: 0.75rem; }
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; }
.kanban { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.kanban-col { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; }
.kanban-header { font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
.kanban-count { font-size: 0.75rem; background: var(--theme-background); border-radius: 10px; padding: 0.1rem 0.4rem; font-weight: 500; }
.cap-card { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
.cap-type-badge { display: inline-block; font-size: 0.65rem; font-weight: 700; border-radius: 3px; padding: 0.1rem 0.35rem; margin-bottom: 0.15rem; }
.cap-priority-badge { display: inline-block; font-size: 0.6rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 0.3rem; }
.cap-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.2rem; line-height: 1.3; }
.cap-domains { font-size: 0.75rem; color: steelblue; font-family: monospace; }
.cap-age { font-size: 0.7rem; color: gray; margin-top: 0.3rem; }
.catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.catalog-domain { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; }
.catalog-domain-header { font-weight: 600; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; border-bottom: 2px solid var(--theme-foreground-faint, #ddd); display: flex; align-items: center; gap: 0.4rem; }
.catalog-entry { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
.catalog-deprecated { opacity: 0.5; }
.catalog-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.15rem; }
.catalog-desc { font-size: 0.75rem; color: var(--theme-foreground-muted, #666); line-height: 1.35; margin-bottom: 0.3rem; }
.catalog-kw { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.kw-tag { font-size: 0.6rem; background: var(--theme-background-alt, #f0f0f0); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 3px; padding: 0.05rem 0.3rem; font-family: monospace; color: var(--theme-foreground-muted, #666); }
</style>

View File

@@ -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="<your-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 = "<uuid>", # optional
priority = "high", # low | medium | high | critical
blocking_task_id = "<task-uuid>" # 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 = "<uuid>",
fulfilling_agent = "railiance-worker",
fulfilling_workstream_id = "<uuid>" # 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.*

177
dashboard/src/docs/scope.md Normal file
View File

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

View File

@@ -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) 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 # Entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

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

View File

@@ -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 <slug> [--repo-path <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 <slug> or --all")
if __name__ == "__main__":
main()

View File

@@ -115,6 +115,23 @@
--- ---
## Provided Capabilities
<!-- What can this repo's domain provide to other domains on request? -->
<!-- Each capability block is parsed by the state-hub capability catalog ingest. -->
<!-- Remove the examples and add your own, or leave empty if none. -->
<!--
```capability
type: infrastructure
title: Example capability title
description: What this capability provides, in one or two sentences.
keywords: [keyword1, keyword2, keyword3]
```
-->
---
## Notes ## Notes
<!-- Anything else worth knowing. Keep it short. --> <!-- Anything else worth knowing. Keep it short. -->

View File

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