diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js
index 6588057..5aeb1cf 100644
--- a/dashboard/observablehq.config.js
+++ b/dashboard/observablehq.config.js
@@ -10,6 +10,8 @@ export default {
{ name: "Domains", path: "/domains" },
{ name: "Extension Points", path: "/extensions" },
{ name: "Technical Debt", path: "/techdept" },
+ { name: "Contributions", path: "/contributions" },
+ { name: "SBOM", path: "/sbom" },
{
name: "Reference",
pages: [
diff --git a/dashboard/src/contributions.md b/dashboard/src/contributions.md
new file mode 100644
index 0000000..9c759e4
--- /dev/null
+++ b/dashboard/src/contributions.md
@@ -0,0 +1,166 @@
+---
+title: Contributions
+---
+
+```js
+const API = "http://127.0.0.1:8000";
+const POLL = 30_000;
+```
+
+```js
+// Live poll for contributions
+const contribState = (async function*() {
+ while (true) {
+ let data = [], ok = false;
+ try {
+ const r = await fetch(`${API}/contributions/`);
+ ok = r.ok;
+ data = ok ? await r.json() : [];
+ } catch {}
+ yield {data, ok, ts: new Date()};
+ await new Promise(res => setTimeout(res, POLL));
+ }
+})();
+```
+
+```js
+const contribs = contribState.data ?? [];
+const _ok = contribState.ok ?? false;
+const _ts = contribState.ts;
+```
+
+# Contributions
+
+```js
+import {injectTocTop} from "./components/toc-sidebar.js";
+
+const _liveEl = html`
+ ●
+ ${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`API offline`}
+
`;
+injectTocTop("live-indicator", _liveEl);
+```
+
+```js
+// Filters
+const typeFilter = Inputs.select(["all", "br", "fr", "ep", "upr"], {label: "Type", value: "all"});
+const statFilter = Inputs.select(
+ ["all", "draft", "submitted", "acknowledged", "accepted", "rejected", "merged", "withdrawn"],
+ {label: "Status", value: "all"}
+);
+const repoFilter = Inputs.text({label: "Target repo", placeholder: "filter by repo…"});
+display(html`
+ ${typeFilter}${statFilter}${repoFilter}
+
`);
+```
+
+```js
+const tf = typeFilter.value;
+const sf = statFilter.value;
+const rf = repoFilter.value?.trim().toLowerCase() ?? "";
+
+const filtered = contribs.filter(c =>
+ (tf === "all" || c.type === tf) &&
+ (sf === "all" || c.status === sf) &&
+ (!rf || (c.target_repo ?? "").toLowerCase().includes(rf))
+);
+```
+
+## Summary
+
+```js
+const typeLabels = {br: "Bug Report", fr: "Feature Request", ep: "Extension Point", upr: "Upstream PR"};
+const typeCounts = Object.fromEntries(["br","fr","ep","upr"].map(t => [
+ t, contribs.filter(c => c.type === t).length
+]));
+const needsFollowUp = contribs.filter(c => ["submitted","acknowledged"].includes(c.status)).length;
+
+display(html`
+
+
Total
+
${contribs.length}
+
+ ${["br","fr","ep","upr"].map(t => html`
+
+
${typeLabels[t]}
+
${typeCounts[t]}
+
+ `)}
+
+${needsFollowUp > 0 ? html`⚠ ${needsFollowUp} contribution(s) awaiting upstream response (submitted / acknowledged)
` : ""}
+`);
+```
+
+## Status Kanban
+
+```js
+const statusCols = [
+ {key: "draft", label: "Draft", color: "#aaa"},
+ {key: "submitted", label: "Submitted", color: "steelblue"},
+ {key: "acknowledged", label: "Acknowledged",color: "#f0a500"},
+ {key: "accepted", label: "Accepted", color: "#4caf50"},
+ {key: "merged", label: "Merged", color: "#2e7d32"},
+ {key: "rejected", label: "Rejected", color: "#e53935"},
+ {key: "withdrawn", label: "Withdrawn", color: "#bbb"},
+];
+
+const colMap = {};
+for (const c of filtered) {
+ (colMap[c.status] = colMap[c.status] ?? []).push(c);
+}
+
+const activeCols = statusCols.filter(s => colMap[s.key]?.length);
+if (activeCols.length === 0) {
+ display(html`No contributions match the current filters.
`);
+} else {
+ display(html`
+ ${activeCols.map(s => html`
+
+
+ ${colMap[s.key].map(c => html`
+
+
${c.type.toUpperCase()}
+
${c.title}
+ ${c.target_org || c.target_repo ? html`
${[c.target_org, c.target_repo].filter(Boolean).join("/")}
` : ""}
+ ${c.body_path ? html`
${c.body_path}
` : ""}
+
${new Date(c.created_at).toLocaleDateString()}
+
+ `)}
+
+ `)}
+
`);
+}
+```
+
+## All Contributions
+
+```js
+display(Inputs.table(filtered.map(c => ({
+ Type: c.type.toUpperCase(),
+ Title: c.title,
+ Status: c.status,
+ Target: [c.target_org, c.target_repo].filter(Boolean).join("/") || "—",
+ Created: new Date(c.created_at).toLocaleDateString(),
+})), {maxWidth: 900}));
+```
+
+
diff --git a/dashboard/src/data/contributions.json.py b/dashboard/src/data/contributions.json.py
new file mode 100644
index 0000000..b1420a0
--- /dev/null
+++ b/dashboard/src/data/contributions.json.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+"""Observable data loader: fetches /contributions/ from the API."""
+import json
+import os
+import urllib.request
+import urllib.error
+
+API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
+
+try:
+ with urllib.request.urlopen(f"{API_BASE}/contributions/", timeout=10) as resp:
+ data = json.loads(resp.read())
+ print(json.dumps(data))
+except urllib.error.URLError as e:
+ print(json.dumps({"error": str(e), "contributions": []}))
diff --git a/dashboard/src/data/sbom.json.py b/dashboard/src/data/sbom.json.py
new file mode 100644
index 0000000..6a9606b
--- /dev/null
+++ b/dashboard/src/data/sbom.json.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+"""Observable data loader: fetches /sbom/ and /sbom/report/licences/ from the API."""
+import json
+import os
+import urllib.request
+import urllib.error
+
+API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
+
+result = {"entries": [], "licence_report": {"groups": [], "copyleft_direct_count": 0}}
+
+try:
+ with urllib.request.urlopen(f"{API_BASE}/sbom/", timeout=10) as resp:
+ result["entries"] = json.loads(resp.read())
+except urllib.error.URLError as e:
+ result["error_entries"] = str(e)
+
+try:
+ with urllib.request.urlopen(f"{API_BASE}/sbom/report/licences/", timeout=10) as resp:
+ result["licence_report"] = json.loads(resp.read())
+except urllib.error.URLError as e:
+ result["error_licences"] = str(e)
+
+print(json.dumps(result))
diff --git a/dashboard/src/index.md b/dashboard/src/index.md
index ab376c7..e737628 100644
--- a/dashboard/src/index.md
+++ b/dashboard/src/index.md
@@ -156,6 +156,32 @@ if (openWs.length === 0) {
}
```
+## Contribution & SBOM Health
+
+```js
+const contribCounts = summary.contribution_counts ?? {};
+const licenceRisk = summary.licence_risk_count ?? 0;
+const totalContribs = ["br","fr","ep","upr"].reduce((s, t) => s + (contribCounts[t] ?? 0), 0);
+const needsFollowUp = (contribCounts["submitted"] ?? 0) + (contribCounts["acknowledged"] ?? 0);
+
+display(html``);
+```
+
## Status
```js
diff --git a/dashboard/src/sbom.md b/dashboard/src/sbom.md
new file mode 100644
index 0000000..76abff6
--- /dev/null
+++ b/dashboard/src/sbom.md
@@ -0,0 +1,150 @@
+---
+title: SBOM
+---
+
+```js
+const API = "http://127.0.0.1:8000";
+```
+
+```js
+// Fetch SBOM data on load
+let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = [];
+try {
+ [_entries, _report, _repos] = await Promise.all([
+ fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []),
+ fetch(`${API}/sbom/report/licences/`).then(r => r.ok ? r.json() : {groups:[], copyleft_direct_count: 0}),
+ fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []),
+ ]);
+} catch {}
+```
+
+```js
+const entries = _entries ?? [];
+const report = _report ?? {groups: [], copyleft_direct_count: 0};
+const repos = _repos ?? [];
+const groups = report.groups ?? [];
+const riskCount = report.copyleft_direct_count ?? 0;
+```
+
+# SBOM
+
+## Licence Risk
+
+```js
+const riskBadge = riskCount === 0
+ ? html`✓ No copyleft in direct prod deps`
+ : html`⚠ ${riskCount} direct prod dep(s) with copyleft licence`;
+display(html`
+
+
Total Packages
+
${entries.length}
+
+
+
Repos Scanned
+
${new Set(entries.map(e => e.repo_id)).size}
+
+
+
Licence Risk
+
${riskCount}
+
${riskBadge}
+
+
+
Unique Licences
+
${groups.length}
+
+
`);
+```
+
+## Licence Distribution
+
+```js
+import * as Plot from "npm:@observablehq/plot";
+
+if (groups.length === 0) {
+ display(html`No SBOM data ingested yet. Run make ingest-sbom REPO=<slug>.
`);
+} else {
+ const plotData = groups.slice(0, 15).map(g => ({
+ licence: g.license_spdx ?? "(unknown)",
+ count: g.count,
+ copyleft: g.is_copyleft,
+ }));
+ display(Plot.plot({
+ x: {label: "Packages"},
+ y: {label: null, domain: plotData.map(d => d.licence)},
+ color: {domain: [false, true], range: ["steelblue", "#e53935"], legend: true, tickFormat: d => d ? "Copyleft" : "Permissive"},
+ marks: [
+ Plot.barX(plotData, {y: "licence", x: "count", fill: "copyleft", tip: true}),
+ Plot.ruleX([0]),
+ ],
+ marginLeft: 130,
+ height: Math.max(80, plotData.length * 30 + 50),
+ width: 600,
+ }));
+}
+```
+
+## Copyleft Risk Detail
+
+```js
+const copyleftGroups = groups.filter(g => g.is_copyleft);
+if (copyleftGroups.length === 0) {
+ display(html`✓ No copyleft packages found.
`);
+} else {
+ display(html`
+ ${copyleftGroups.map(g => html`
+
+ ${g.license_spdx ?? "unknown"}
+ ${g.count} package(s)
+ ${g.repos.join(", ")}
+
+ `)}
+
`);
+}
+```
+
+## Package Table
+
+```js
+// Filters
+const ecoFilter = Inputs.select(["all", "python", "node", "rust", "go", "java", "other"], {label: "Ecosystem", value: "all"});
+const directOnly = Inputs.toggle({label: "Direct deps only", value: false});
+const prodOnly = Inputs.toggle({label: "Prod deps only (no dev)", value: false});
+display(html`
+ ${ecoFilter}${directOnly}${prodOnly}
+
`);
+```
+
+```js
+// Build repo_id → slug lookup
+const repoById = Object.fromEntries(_repos.map(r => [r.id, r.slug]));
+
+const filteredEntries = entries.filter(e =>
+ (ecoFilter.value === "all" || e.ecosystem === ecoFilter.value) &&
+ (!directOnly.value || e.is_direct) &&
+ (!prodOnly.value || !e.is_dev)
+);
+
+display(Inputs.table(filteredEntries.map(e => ({
+ Package: e.package_name,
+ Version: e.package_version ?? "—",
+ Ecosystem: e.ecosystem,
+ Licence: e.license_spdx ?? "—",
+ Repo: repoById[e.repo_id] ?? e.repo_id?.slice(0, 8) ?? "—",
+ Direct: e.is_direct ? "✓" : "",
+ Dev: e.is_dev ? "✓" : "",
+})), {maxWidth: 900}));
+```
+
+
diff --git a/mcp_server/server.py b/mcp_server/server.py
index 8a95615..fcf089e 100644
--- a/mcp_server/server.py
+++ b/mcp_server/server.py
@@ -813,6 +813,155 @@ def validate_repo_adr(repo_path: str, domain_slug: str | None = None) -> str:
return "\n".join(lines)
+# ---------------------------------------------------------------------------
+# Contribution tracking (v0.3)
+# ---------------------------------------------------------------------------
+
+@mcp.resource("state://contributions")
+def resource_contributions() -> str:
+ """All contribution artifacts (BR/FR/EP/UPR)."""
+ return json.dumps(_get("/contributions"), indent=2)
+
+
+@mcp.resource("state://sbom/aggregated")
+def resource_sbom_aggregated() -> str:
+ """Aggregated SBOM entries across all repos."""
+ return json.dumps(_get("/sbom"), indent=2)
+
+
+@mcp.resource("state://sbom/{repo_slug}")
+def resource_sbom_repo(repo_slug: str) -> str:
+ """SBOM view for a specific repo (by slug)."""
+ return json.dumps(_get(f"/sbom/{repo_slug}"), indent=2)
+
+
+@mcp.tool()
+def register_contribution(
+ type: str,
+ title: str,
+ target_org: str | None = None,
+ target_repo: str | None = None,
+ body_path: str | None = None,
+ related_workstream_id: str | None = None,
+ notes: str | None = None,
+) -> str:
+ """Register a new upstream contribution artifact (BR/FR/EP/UPR).
+
+ Args:
+ type: br | fr | ep | upr
+ title: Short human-readable title
+ target_org: GitHub org or owner of the upstream project
+ target_repo: Repository name of the upstream project
+ body_path: Relative path to the Markdown artifact file in the repo
+ related_workstream_id: UUID of the related workstream (optional)
+ notes: Any additional notes (optional)
+ """
+ contrib = _post("/contributions", {
+ "type": type,
+ "title": title,
+ "target_org": target_org,
+ "target_repo": target_repo,
+ "body_path": body_path,
+ "related_workstream_id": related_workstream_id,
+ "notes": notes,
+ })
+ _post("/progress", {
+ "workstream_id": related_workstream_id,
+ "event_type": "contribution_registered",
+ "summary": f"Contribution registered [{type.upper()}]: {title}",
+ "author": "custodian",
+ "detail": {
+ "contribution_id": contrib["id"],
+ "type": type,
+ "target": f"{target_org}/{target_repo}" if target_org else target_repo,
+ "body_path": body_path,
+ },
+ })
+ return json.dumps(contrib, indent=2)
+
+
+@mcp.tool()
+def update_contribution_status(
+ contribution_id: str,
+ status: str,
+ notes: str | None = None,
+) -> str:
+ """Update the status of a contribution artifact.
+
+ Valid transitions: draft→submitted→acknowledged→accepted→merged
+ ↘ ↘
+ rejected withdrawn
+
+ Args:
+ contribution_id: UUID of the contribution
+ status: submitted | acknowledged | accepted | rejected | merged | withdrawn
+ notes: Optional context for the status change
+ """
+ contrib = _patch(f"/contributions/{contribution_id}/status", {
+ "status": status,
+ "notes": notes,
+ })
+ _post("/progress", {
+ "event_type": "contribution_status_changed",
+ "summary": f"Contribution status → {status}: {contrib['title']}",
+ "author": "custodian",
+ "detail": {"contribution_id": contribution_id, "status": status, "notes": notes},
+ })
+ return json.dumps(contrib, indent=2)
+
+
+@mcp.tool()
+def get_contributions(
+ type: str | None = None,
+ status: str | None = None,
+ target_repo: str | None = None,
+) -> str:
+ """List contribution artifacts, optionally filtered.
+
+ Args:
+ type: br | fr | ep | upr (optional)
+ status: draft | submitted | acknowledged | accepted | rejected | merged | withdrawn (optional)
+ target_repo: filter by upstream repo name (optional)
+ """
+ return json.dumps(_get("/contributions", {
+ "type": type, "status": status, "target_repo": target_repo,
+ }), indent=2)
+
+
+@mcp.tool()
+def ingest_sbom_tool(repo_slug: str, lockfile_path: str) -> str:
+ """Ingest a lockfile into the State Hub SBOM store for a repo.
+
+ Parses the lockfile and POSTs entries to /sbom/ingest/. Old entries
+ for the repo are replaced (snapshot strategy).
+
+ Args:
+ repo_slug: Managed-repo slug (must be registered via register_repo)
+ lockfile_path: Absolute path to the lockfile (uv.lock, package-lock.json, Cargo.lock, etc.)
+ """
+ import subprocess
+ script = Path(__file__).parent.parent / "scripts" / "ingest_sbom.py"
+ result = subprocess.run(
+ [sys.executable, str(script), "--repo", repo_slug,
+ "--lockfile", lockfile_path, "--api-base", API_BASE],
+ capture_output=True, text=True,
+ )
+ output = (result.stdout + result.stderr).strip()
+ if result.returncode != 0:
+ return f"ingest_sbom failed (exit {result.returncode}):\n{output}"
+ return output
+
+
+@mcp.tool()
+def get_licence_report() -> str:
+ """Get a licence report across all ingested SBOM entries.
+
+ Returns packages grouped by SPDX licence identifier, with copyleft
+ flag (GPL/AGPL/LGPL/EUPL/CDDL/MPL) and repos using each licence.
+ """
+ return json.dumps(_get("/sbom/report/licences"), indent=2)
+
+
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------