Files
the-custodian/state-hub/dashboard/src/capability-requests.md
tegwick efbbef76b0 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>
2026-03-21 23:58:52 +01:00

12 KiB

title
title
Capability Requests
import {API} from "./components/config.js";
const POLL = 30_000;
// 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));
  }
})();
const requests = reqState.data ?? [];
const _ok = reqState.ok ?? false;
const _ts = reqState.ts;

Capability Requests

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"); }
// KPI sidebar
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)
  : "—";
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);
// Filters
const typeFilter = Inputs.select(
  ["all", ...new Set(requests.map(r => r.capability_type))],
  {label: "Type", value: "all"}
);
const statFilter = Inputs.select(
  ["all", "routing_disputed", "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>`);
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

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

const statusCols = [
  {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 = {};
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

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

// 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));
  }
})();
const catalog = catalogState ?? [];
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); } .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>