Compare commits
2 Commits
e423ff7126
...
efbbef76b0
| Author | SHA1 | Date | |
|---|---|---|---|
| efbbef76b0 | |||
| e31693ad67 |
@@ -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')
|
||||
220
workplans/CUST-WP-0027-capability-request-dispute.md
Normal file
220
workplans/CUST-WP-0027-capability-request-dispute.md
Normal file
@@ -0,0 +1,220 @@
|
||||
---
|
||||
id: CUST-WP-0027
|
||||
type: workplan
|
||||
title: "Capability Request Dispute & Negotiation"
|
||||
domain: custodian
|
||||
repo: the-custodian
|
||||
status: done
|
||||
owner: custodian
|
||||
topic_slug: custodian
|
||||
created: "2026-03-21"
|
||||
updated: "2026-03-21"
|
||||
state_hub_workstream_id: "72d62c88-3745-48ad-9e18-44df51ab21a4"
|
||||
---
|
||||
|
||||
# CUST-WP-0027 — Capability Request Dispute & Negotiation
|
||||
|
||||
## Problem
|
||||
|
||||
The routing algorithm is keyword-based and makes mistakes. When a request
|
||||
is misrouted (e.g. "KeyCape container image published to GHCR" → railiance
|
||||
instead of netkingdom), there is no structured way for any party to:
|
||||
|
||||
1. Flag the routing as wrong
|
||||
2. Suggest the correct domain
|
||||
3. Pause the workflow while the routing is negotiated
|
||||
4. Have the custodian re-route with an audit trail
|
||||
|
||||
Today the only recourse is a manual PATCH to `catalog_entry_id` —
|
||||
discoverable only if you know the API. The lifecycle skips straight from
|
||||
`requested` to `accepted` with no dispute window.
|
||||
|
||||
## Goal
|
||||
|
||||
Any agent (requester, proposed fulfiller, custodian) can:
|
||||
- Dispute a routing decision with a reason and an optional suggested domain
|
||||
- See disputed requests prominently in the dashboard
|
||||
- Re-route the request to the correct domain (custodian action)
|
||||
- Resume the normal lifecycle once routing is settled
|
||||
|
||||
## Design
|
||||
|
||||
### New status: `routing_disputed`
|
||||
|
||||
Insert between `requested` and `accepted`:
|
||||
|
||||
```
|
||||
requested
|
||||
├── accepted (happy path — no dispute)
|
||||
├── routing_disputed (any party flags misrouting)
|
||||
│ ├── requested (re-routed — back to start with new domain)
|
||||
│ └── withdrawn
|
||||
├── rejected
|
||||
└── withdrawn
|
||||
```
|
||||
|
||||
### New DB columns on `capability_requests`
|
||||
|
||||
| Column | Type | Purpose |
|
||||
|--------|------|---------|
|
||||
| `dispute_reason` | Text nullable | Why the routing is wrong |
|
||||
| `disputed_by` | String(100) nullable | Agent who raised the dispute |
|
||||
| `dispute_suggested_domain` | String(100) nullable | Where it should go instead |
|
||||
| `disputed_at` | DateTime nullable | When the dispute was raised |
|
||||
|
||||
No new table needed — disputes are single-valued (one active dispute at a
|
||||
time per request). History is in `routing_note` (appended on each action).
|
||||
|
||||
### New endpoints
|
||||
|
||||
**`POST /capability-requests/{id}/dispute`**
|
||||
- Body: `{reason: str, suggested_domain: str | null, disputed_by: str}`
|
||||
- Allowed from: `requested` status only
|
||||
- Sets status → `routing_disputed`, fills dispute columns
|
||||
- Notifies: custodian + current fulfilling domain agent
|
||||
|
||||
**`POST /capability-requests/{id}/reroute`**
|
||||
- Body: `{domain: str | null, catalog_entry_id: UUID | null, note: str, rerouted_by: str}`
|
||||
- Allowed from: `routing_disputed` status only
|
||||
- Changes `fulfilling_domain_id` (via catalog entry or direct domain lookup)
|
||||
- Clears dispute columns
|
||||
- Appends to `routing_note`
|
||||
- Sets status → `requested`
|
||||
- Notifies: requester + new fulfilling domain agent
|
||||
|
||||
### Updated `_VALID_TRANSITIONS`
|
||||
|
||||
```python
|
||||
_VALID_TRANSITIONS = {
|
||||
"requested": {"accepted", "rejected", "withdrawn", "routing_disputed"},
|
||||
"routing_disputed": {"requested", "withdrawn"},
|
||||
"accepted": {"in_progress", "rejected", "withdrawn"},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — DB migration: dispute columns
|
||||
|
||||
```task
|
||||
id: CUST-WP-0027-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "41357812-9791-488d-883b-75cb07ae4c90"
|
||||
```
|
||||
|
||||
Add Alembic migration with four new nullable columns on
|
||||
`capability_requests`:
|
||||
- `dispute_reason: Text`
|
||||
- `disputed_by: String(100)`
|
||||
- `dispute_suggested_domain: String(100)`
|
||||
- `disputed_at: DateTime(timezone=True)`
|
||||
|
||||
Update `CapabilityRequest` model and `CapabilityRequestRead` schema.
|
||||
|
||||
Gate: `make migrate` runs cleanly; `make test` passes.
|
||||
|
||||
---
|
||||
|
||||
### T02 — Dispute endpoint + `routing_disputed` status
|
||||
|
||||
```task
|
||||
id: CUST-WP-0027-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "39429f29-cf3a-440c-aca7-5b9e3b31b2d0"
|
||||
```
|
||||
|
||||
- Add `routing_disputed` to `_VALID_TRANSITIONS`
|
||||
- Add `POST /capability-requests/{id}/dispute` endpoint:
|
||||
- Body schema: `CapabilityRequestDispute(reason, suggested_domain, disputed_by)`
|
||||
- Guard: only from `requested` status
|
||||
- Fills dispute columns, sets status
|
||||
- Notifies custodian and current fulfilling domain
|
||||
- Add `CapabilityRequestDispute` Pydantic schema
|
||||
|
||||
Gate: `make test` passes; dispute transitions correctly; custodian +
|
||||
fulfilling domain receive notifications.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Reroute endpoint
|
||||
|
||||
```task
|
||||
id: CUST-WP-0027-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "94ede349-ddd1-4572-bb18-43cb2e67326b"
|
||||
```
|
||||
|
||||
- Add `POST /capability-requests/{id}/reroute` endpoint:
|
||||
- Body schema: `CapabilityRequestReroute(domain, catalog_entry_id, note, rerouted_by)`
|
||||
- Guard: only from `routing_disputed` status
|
||||
- Accept `catalog_entry_id` (preferred, re-derives domain) OR `domain` slug (direct)
|
||||
- Clear dispute columns
|
||||
- Append to `routing_note`: `"re-routed by {rerouted_by}: {note}"`
|
||||
- Set status → `requested`
|
||||
- Notify: requester + new fulfilling domain
|
||||
- Add `CapabilityRequestReroute` Pydantic schema
|
||||
|
||||
Gate: full dispute→reroute→accept round-trip passes in tests.
|
||||
|
||||
---
|
||||
|
||||
### T04 — MCP tools
|
||||
|
||||
```task
|
||||
id: CUST-WP-0027-T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "e8d1e24e-8ec7-4c4a-b2b5-f6072ab7582c"
|
||||
```
|
||||
|
||||
Add two MCP tools to the state-hub MCP server:
|
||||
|
||||
**`dispute_capability_routing(request_id, reason, suggested_domain, disputed_by)`**
|
||||
- Calls `POST /capability-requests/{id}/dispute`
|
||||
- Returns updated request
|
||||
|
||||
**`reroute_capability_request(request_id, domain, note, rerouted_by, catalog_entry_id)`**
|
||||
- Calls `POST /capability-requests/{id}/reroute`
|
||||
- Returns updated request
|
||||
|
||||
Gate: both tools callable from Claude Code; test against the misrouting
|
||||
pattern (route → dispute → reroute → accept).
|
||||
|
||||
---
|
||||
|
||||
### T05 — Dashboard: highlight disputed requests
|
||||
|
||||
```task
|
||||
id: CUST-WP-0027-T05
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "a40b6a57-2cfd-4e09-ac00-3d4d2920eb7a"
|
||||
```
|
||||
|
||||
Update `state-hub/dashboard/src/capability-requests.md`:
|
||||
- Add a `routing_disputed` filter/badge (amber, same pattern as
|
||||
interventions)
|
||||
- Show `dispute_reason` and `dispute_suggested_domain` in the detail view
|
||||
- Disputed requests should appear at the top of the list regardless of
|
||||
creation order
|
||||
|
||||
Gate: a `routing_disputed` request renders with amber badge and dispute
|
||||
details visible.
|
||||
|
||||
---
|
||||
|
||||
## Done Criteria
|
||||
|
||||
- [ ] `POST /capability-requests/{id}/dispute` transitions to `routing_disputed`
|
||||
and notifies custodian + current fulfilling domain
|
||||
- [ ] `POST /capability-requests/{id}/reroute` re-routes and resets to `requested`
|
||||
with full audit trail in `routing_note`
|
||||
- [ ] `dispute_capability_routing` and `reroute_capability_request` MCP tools work
|
||||
- [ ] Dashboard shows disputed requests with amber badge
|
||||
- [ ] `make test` passes after all changes
|
||||
Reference in New Issue
Block a user