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