feat(classification-spine): implement STATE-WP-0065 repo-anchored model

Replace the ad-hoc coordination-domain spine with the Repo Classification
Standard: 14 market domains, classification columns on managed_repos, and
workplans anchored by repo_id (topic_id optional).

- Add Alembic migration d8e9f0a1b2c3 with data backfill and workstream→workplan rename
- Add api/classification.py validation and register-from-classification tooling
- Expose workplan-first REST/MCP surface with legacy workstream aliases
- Add C-24 consistency rule and legacy domain frontmatter mapping
- Update dashboard repos page with category/capability/stake filters
- Update orientation docs; mark STATE-WP-0065 finished
This commit is contained in:
2026-06-22 13:52:13 +02:00
parent 279be4ffbd
commit 0949d4c0d8
84 changed files with 4494 additions and 1111 deletions

View File

@@ -102,3 +102,62 @@ async def client(test_engine):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Shared entity helpers (workplan-first; legacy workstream names retained)
# ---------------------------------------------------------------------------
async def create_test_domain(client, slug="infotech", name="Infotech"):
r = await client.post("/domains/", json={"slug": slug, "name": name})
assert r.status_code == 201, r.text
return r.json()
async def create_test_topic(client, domain_slug="infotech", slug="testtopic", title="Test Topic"):
r = await client.post("/topics/", json={
"slug": slug, "title": title, "domain": domain_slug,
})
assert r.status_code == 201, r.text
return r.json()
async def create_test_repo(client, domain_slug="infotech", slug="test-repo", **extra):
payload = {
"domain_slug": domain_slug,
"slug": slug,
"name": "Test Repo",
**extra,
}
r = await client.post("/repos/", json=payload)
assert r.status_code == 201, r.text
return r.json()
async def create_test_workplan(
client,
repo_id,
topic_id=None,
slug="test-wp",
title="Test Workplan",
status="active",
**extra,
):
payload = {"repo_id": repo_id, "slug": slug, "title": title, "status": status, **extra}
if topic_id is not None:
payload["topic_id"] = topic_id
r = await client.post("/workplans/", json=payload)
assert r.status_code == 201, r.text
return r.json()
async def create_test_workstream(client, topic_id=None, repo_id=None, slug="test-wp", **kwargs):
"""Legacy helper name — creates a repo-anchored workplan."""
if repo_id is None:
domain = await create_test_domain(client)
if topic_id is None:
topic = await create_test_topic(client, domain_slug=domain["slug"])
topic_id = topic["id"]
repo = await create_test_repo(client, domain_slug=domain["slug"], slug=f"{slug}-repo")
repo_id = repo["id"]
return await create_test_workplan(client, repo_id=repo_id, topic_id=topic_id, slug=slug, **kwargs)

View File

@@ -27,17 +27,19 @@ async def _create_topic(client, domain_slug="testdomain"):
return r.json()
async def _create_workstream(client, topic_id):
r = await client.post("/workstreams/", json={
"topic_id": topic_id, "slug": "test-ws", "title": "Test WS",
})
assert r.status_code == 201, r.text
return r.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_task(client, workstream_id, title="Test task", status="wait"):
async def _create_workstream(client, topic_id, domain_slug="custodian"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug="test-repo")
return await create_test_workplan(
client, repo_id=repo["id"], topic_id=topic_id, slug="test-ws", title="Test WS",
)
async def _create_task(client, workplan_id, title="Test task", status="wait"):
r = await client.post("/tasks/", json={
"workstream_id": workstream_id, "title": title,
"workplan_id": workplan_id, "title": title,
})
assert r.status_code == 201, r.text
task = r.json()

View File

@@ -0,0 +1,67 @@
"""Tests for api.classification validation module (STATE-WP-0065 P2)."""
from __future__ import annotations
import pytest
from api.classification import (
ClassificationData,
validate_classification,
)
def _valid_block(**overrides) -> dict:
base = {
"category": "tooling",
"domain": "infotech",
"secondary_domains": [],
"capability_tags": ["platform"],
"business_stake": ["technology", "operations"],
"business_mechanics": ["coordination"],
}
base.update(overrides)
return base
class TestValidateClassification:
def test_valid_block_passes(self):
errors, warnings = validate_classification(_valid_block())
assert errors == []
def test_missing_category_fails(self):
block = _valid_block()
del block["category"]
errors, _ = validate_classification(block)
assert any("category" in err for err in errors)
def test_invalid_category_fails(self):
errors, _ = validate_classification(_valid_block(category="not-a-category"))
assert any("category" in err for err in errors)
def test_invalid_domain_fails(self):
errors, _ = validate_classification(_valid_block(domain="not-a-domain"))
assert any("domain" in err for err in errors)
def test_unknown_capability_tag_warns(self):
_, warnings = validate_classification(_valid_block(capability_tags=["totally-made-up-tag"]))
assert any("capability_tag" in warn for warn in warnings)
def test_invalid_business_stake_fails(self):
errors, _ = validate_classification(_valid_block(business_stake=["not-a-stake"]))
assert any("business_stake" in err for err in errors)
def test_secondary_domain_repeats_primary_fails(self):
errors, _ = validate_classification(
_valid_block(domain="infotech", secondary_domains=["infotech"])
)
assert any("repeats the primary domain" in err for err in errors)
class TestClassificationData:
def test_round_trip_dict(self):
block = _valid_block(classified_at="2026-06-22", classified_by="human", version="1.0")
data = ClassificationData.from_block(block)
payload = data.to_dict()
assert payload["category"] == "tooling"
assert payload["domain"] == "infotech"
assert payload["classified_by"] == "human"
assert payload["standard_version"] == "1.0"

View File

@@ -23,6 +23,7 @@ import pytest
# Make scripts/ importable without installing
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from api.classification import resolve_topic_domain_slug
from consistency_check import (
ConsistencyReport,
Issue,
@@ -54,6 +55,15 @@ from api.workplan_status import ready_review_status
# for backward compat; their canonical implementations live in repo_sync.py.
# ---------------------------------------------------------------------------
# domain mapping (STATE-WP-0065 P4)
# ---------------------------------------------------------------------------
class TestResolveTopicDomainSlug:
def test_workplan_custodian_maps_to_infotech(self):
assert resolve_topic_domain_slug("custodian", repo_market_domain="infotech") == "infotech"
# ---------------------------------------------------------------------------
# parse_frontmatter
# ---------------------------------------------------------------------------
@@ -372,7 +382,7 @@ class TestRenderText:
r.add(severity="WARN", check_id="C-04", message="w")
r.add(severity="INFO", check_id="C-08", message="i")
text = render_text(r)
assert "1 fail" in text
assert "1 assessment-fail" in text
assert "1 warn" in text
assert "1 info" in text
@@ -443,7 +453,7 @@ class TestReportToDict:
r = ConsistencyReport(repo_slug="r", repo_path="/p")
d = report_to_dict(r)
assert d["result"] == "pass"
assert d["summary"] == {"fail": 0, "warn": 0, "info": 0}
assert d["summary"] == {"fail": 0, "automation_error": 0, "warn": 0, "info": 0}
assert d["issues"] == []
def test_fail_result(self):

View File

@@ -17,8 +17,20 @@ async def _create_topic(client, domain_slug="legacy-domain", slug="legacy-topic"
return r.json()
async def _create_workplan(client, topic_id, slug="legacy-wp", title="Legacy WP"):
async def _create_repo(client, domain_slug="legacy-domain", slug="legacy-repo"):
r = await client.post("/repos/", json={
"domain_slug": domain_slug,
"slug": slug,
"name": "Legacy Repo",
})
assert r.status_code == 201, r.text
return r.json()
async def _create_workplan(client, topic_id, domain_slug="legacy-domain", slug="legacy-wp", title="Legacy WP"):
repo = await _create_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
r = await client.post("/workplans/", json={
"repo_id": repo["id"],
"topic_id": topic_id,
"slug": slug,
"title": title,

View File

@@ -28,12 +28,14 @@ async def _create_topic(client, domain_slug="mcp-domain"):
return r.json()
async def _create_workstream(client, topic_id):
r = await client.post("/workstreams/", json={
"topic_id": topic_id, "slug": "mcp-ws", "title": "MCP Workstream",
})
assert r.status_code == 201
return r.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_workstream(client, topic_id, domain_slug="mcp-domain"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug="mcp-repo")
return await create_test_workplan(
client, repo_id=repo["id"], topic_id=topic_id, slug="mcp-ws", title="MCP Workstream",
)
# ---------------------------------------------------------------------------

View File

@@ -19,15 +19,16 @@ async def _call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
class TestMCPWriteTools:
async def test_create_workstream_returns_rest_shape_and_emits_progress(self, monkeypatch):
async def test_create_workplan_returns_rest_shape_and_emits_progress(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body))
if path == "/workstreams":
if path == "/workplans":
return {
"id": "ws-1",
"topic_id": body["topic_id"],
"id": "wp-1",
"repo_id": body["repo_id"],
"topic_id": body.get("topic_id"),
"title": body["title"],
"slug": body["slug"],
"status": body["status"],
@@ -39,20 +40,42 @@ class TestMCPWriteTools:
monkeypatch.setattr(server, "_post", fake_post)
body = await _call_tool(
"create_workstream",
{"topic_id": "topic-1", "title": "MCP Reliable Write"},
"create_workplan",
{"repo_id": "repo-1", "topic_id": "topic-1", "title": "MCP Reliable Write"},
)
assert body == {
"id": "ws-1",
"id": "wp-1",
"repo_id": "repo-1",
"topic_id": "topic-1",
"title": "MCP Reliable Write",
"slug": "mcp-reliable-write",
"status": "active",
}
assert [path for path, _ in calls] == ["/workstreams", "/progress"]
assert calls[1][1]["workstream_id"] == "ws-1"
assert calls[1][1]["event_type"] == "workstream_created"
assert [path for path, _ in calls] == ["/workplans", "/progress"]
assert calls[1][1]["workplan_id"] == "wp-1"
assert calls[1][1]["event_type"] == "workplan_created"
async def test_create_workstream_legacy_alias_uses_workplans_endpoint(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body))
if path == "/workplans":
return {"id": "wp-1", "repo_id": body["repo_id"], "title": body["title"], "slug": body["slug"], "status": "active"}
if path == "/progress":
return {"id": "event-1", **body}
raise AssertionError(f"unexpected POST {path}")
monkeypatch.setattr(server, "_post", fake_post)
body = await _call_tool(
"create_workstream",
{"repo_id": "repo-1", "title": "Legacy alias"},
)
assert body["id"] == "wp-1"
assert [path for path, _ in calls] == ["/workplans", "/progress"]
async def test_create_task_returns_rest_shape_and_emits_progress(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []
@@ -62,7 +85,8 @@ class TestMCPWriteTools:
if path == "/tasks":
return {
"id": "task-1",
"workstream_id": body["workstream_id"],
"workplan_id": body.get("workplan_id") or body.get("workstream_id"),
"workstream_id": body.get("workplan_id") or body.get("workstream_id"),
"title": body["title"],
"priority": body["priority"],
"status": "todo",
@@ -80,6 +104,7 @@ class TestMCPWriteTools:
assert body == {
"id": "task-1",
"workplan_id": "ws-1",
"workstream_id": "ws-1",
"title": "MCP task",
"priority": "high",
@@ -266,18 +291,18 @@ class TestMCPWriteTools:
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body))
return {"error": "API 422: invalid topic"}
return {"error": "API 422: invalid repo"}
monkeypatch.setattr(server, "_post", fake_post)
body = await _call_tool(
"create_workstream",
{"topic_id": "bad-topic", "title": "No progress on failure"},
{"repo_id": "bad-repo", "title": "No progress on failure"},
)
assert body["tool"] == "create_workstream"
assert body["error"] == "API 422: invalid topic"
assert [path for path, _ in calls] == ["/workstreams"]
assert body["error"] == "API 422: invalid repo"
assert [path for path, _ in calls] == ["/workplans"]
async def test_record_decision_missing_id_is_clear_and_skips_progress(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []

View File

@@ -20,14 +20,18 @@ async def _create_topic(client, domain_slug="digest", slug="digest-topic", title
return response.json()
async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream"):
response = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": title})
assert response.status_code == 201, response.text
return response.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_task(client, workstream_id, title="Digest task"):
response = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title})
async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream", domain_slug="digest"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
return await create_test_workplan(
client, repo_id=repo["id"], topic_id=topic_id, slug=slug, title=title,
)
async def _create_task(client, workplan_id, title="Digest task"):
response = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": title})
assert response.status_code == 201, response.text
return response.json()
@@ -277,12 +281,16 @@ class TestRecentlyOnScopeRoutes:
await _create_domain(client, slug="broken", name="Broken Domain")
broken_topic = await _create_topic(client, domain_slug="broken", slug="broken-topic")
broken_workstream = await _create_workstream(client, broken_topic["id"], slug="broken-ws")
broken_workstream = await _create_workstream(
client, broken_topic["id"], slug="broken-ws", domain_slug="broken",
)
await _create_task(client, broken_workstream["id"], title="Broken source")
await _create_domain(client, slug="good", name="Good Domain")
good_topic = await _create_topic(client, domain_slug="good", slug="good-topic")
good_workstream = await _create_workstream(client, good_topic["id"], slug="good-ws")
good_workstream = await _create_workstream(
client, good_topic["id"], slug="good-ws", domain_slug="good",
)
await _create_task(client, good_workstream["id"], title="Good source")
response = await client.post("/recently-on-scope/hourly", json={"range": "1d"})

View File

@@ -0,0 +1,71 @@
"""Tests for register_from_classification CLI (STATE-WP-0065 P3)."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "scripts" / "register_from_classification.py"
def test_cli_help():
result = subprocess.run(
[sys.executable, str(SCRIPT), "--help"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
assert result.returncode == 0
assert "--repo-path" in result.stdout
assert "--bulk" in result.stdout
assert "--dry-run" in result.stdout
@pytest.mark.asyncio
async def test_dry_run_repo_path_state_hub():
sys.path.insert(0, str(REPO_ROOT))
from scripts.register_from_classification import run_registration
import argparse
args = argparse.Namespace(
repo_path=str(REPO_ROOT),
slug=None,
bulk=False,
dry_run=True,
api=False,
db=False,
api_base="http://127.0.0.1:8000",
json=False,
)
report = await run_registration(args)
counts = report.counts()
assert counts["invalid"] == 0
assert counts["registered"] + counts["updated"] + counts["skipped"] >= 1
assert any(r.slug == "state-hub" for r in report.results)
# Valid classification file is always parsed even when DB domains are absent.
assert not any("repo_classification block" in r.detail for r in report.results)
def test_json_report_shape():
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--repo-path",
str(REPO_ROOT),
"--dry-run",
"--json",
],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
payload = json.loads(result.stdout)
assert payload["summary"]["invalid"] == 0
assert "summary" in payload
assert "results" in payload
assert set(payload["summary"]) == {"registered", "updated", "skipped", "invalid"}

View File

@@ -28,19 +28,34 @@ async def _create_topic(client, domain_slug="testdomain", slug="testtopic", titl
return r.json()
async def _create_workstream(client, topic_id, slug="test-ws", title="Test WS", status="active", **extra):
async def _create_workplan(client, repo_id, topic_id=None, slug="test-ws", title="Test WS", status="active", **extra):
payload = {
"topic_id": topic_id, "slug": slug, "title": title, "status": status,
"repo_id": repo_id, "slug": slug, "title": title, "status": status,
}
if topic_id is not None:
payload["topic_id"] = topic_id
payload.update(extra)
r = await client.post("/workstreams/", json=payload)
r = await client.post("/workplans/", json=payload)
assert r.status_code == 201, r.text
return r.json()
async def _create_task(client, workstream_id, title="Test task"):
async def _create_workstream(client, topic_id=None, repo_id=None, slug="test-ws", title="Test WS", status="active", **extra):
if repo_id is None:
if topic_id is None:
await _create_domain(client)
topic = await _create_topic(client)
topic_id = topic["id"]
repo = await _create_repo(client, slug=f"{slug}-repo")
repo_id = repo["id"]
return await _create_workplan(
client, repo_id=repo_id, topic_id=topic_id, slug=slug, title=title, status=status, **extra
)
async def _create_task(client, workplan_id, title="Test task"):
r = await client.post("/tasks/", json={
"workstream_id": workstream_id, "title": title,
"workplan_id": workplan_id, "title": title,
})
assert r.status_code == 201, r.text
return r.json()

View File

@@ -16,19 +16,20 @@ async def _create_topic(client, domain_slug: str = "bulk-domain"):
return r.json()
async def _create_workstream(client, topic_id: str):
r = await client.post(
"/workstreams/",
json={"topic_id": topic_id, "slug": "bulk-ws", "title": "Bulk Workstream"},
from tests.conftest import create_test_repo, create_test_workplan
async def _create_workstream(client, topic_id: str, domain_slug: str = "bulk-domain"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug="bulk-repo")
return await create_test_workplan(
client, repo_id=repo["id"], topic_id=topic_id, slug="bulk-ws", title="Bulk Workstream",
)
assert r.status_code == 201
return r.json()
async def _create_task(client, workstream_id: str, title: str):
async def _create_task(client, workplan_id: str, title: str):
r = await client.post(
"/tasks/",
json={"workstream_id": workstream_id, "title": title},
json={"workplan_id": workplan_id, "title": title},
)
assert r.status_code == 201
return r.json()

View File

@@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="testdomain"):
return r.json()
async def _create_workstream(client, topic_id, slug="ws1"):
r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": "WS"})
assert r.status_code == 201, r.text
return r.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_task(client, workstream_id):
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": "task"})
async def _create_workstream(client, topic_id, slug="ws1", domain_slug="testdomain"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
return await create_test_workplan(client, repo_id=repo["id"], topic_id=topic_id, slug=slug, title="WS")
async def _create_task(client, workplan_id):
r = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": "task"})
assert r.status_code == 201, r.text
return r.json()

View File

@@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="td"):
return r.json()
async def _create_workstream(client, topic_id):
r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": "ws", "title": "WS"})
assert r.status_code == 201, r.text
return r.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_task(client, workstream_id, title="my task"):
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title})
async def _create_workstream(client, topic_id, domain_slug="td"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug="td-repo")
return await create_test_workplan(client, repo_id=repo["id"], topic_id=topic_id, slug="ws", title="WS")
async def _create_task(client, workplan_id, title="my task"):
r = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": title})
assert r.status_code == 201, r.text
return r.json()