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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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')
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user