diff --git a/AGENTS.md b/AGENTS.md index 55fbaf1..5adfcdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,11 @@ there is no MCP server for Codex agents. | Context | URL | |---------|-----| -| Local workstation | `http://127.0.0.1:8000` | -| Remote via tunnel | `http://127.0.0.1:18000` | +| Primary (cluster via tunnel) | `http://127.0.0.1:8000` | +| Remote machine mesh | `http://127.0.0.1:18000` | + +Cluster operating model (access, rollback, backups): +[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) ### Orient at session start diff --git a/INTENT.md b/INTENT.md index 483d1ac..099f48b 100644 --- a/INTENT.md +++ b/INTENT.md @@ -42,6 +42,11 @@ general application platform. When State Hub detects facts, it records and exposes them; when new work must be spawned from events, that responsibility belongs to activity-core or a human-approved workflow. +**Sanctioned writes** (STATE-WP-0061): besides `resolve_decision` and progress +events, the hub may persist gated needs as `Suggestion` records, bump demand +`relevance`, vet/decline/promote them into real tasks, and project WSJF ranking +as a read model for daily triage. + --- ## What it is diff --git a/README.md b/README.md index 1bf290f..f73cf83 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ then run consistency sync. All services bind to `127.0.0.1` only — nothing exposed to the network. +**Production:** the primary State Hub API runs on coulombcore-k3s. Workstation +port `8000` reaches it through the ops-bridge `state-hub-primary` tunnel. See +[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access, +rollback, backups, and pragmatic limitations. + --- ## Setup diff --git a/SCOPE.md b/SCOPE.md index 6737a7e..d0975ac 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -15,7 +15,8 @@ tooling, and dashboard telemetry. - repo registration (classification-driven) and consistency synchronization - repo classification spine (14 market domains, `.repo-classification.yaml`) - task-flow engine and flow definitions -- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking +- SBOM, contribution, capability, demand-weighted suggestion backlog, TPSC, DoI, + token, and interface-change tracking - State Hub tests, operational docs, policies, prompts, and local infra ## Out Of Scope diff --git a/api/main.py b/api/main.py index bc14b74..db63efc 100644 --- a/api/main.py +++ b/api/main.py @@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse from api.database import engine from api.events import shutdown_publisher from api.services.write_idempotency import WriteIdempotencyMiddleware -from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies +from api.routers import decisions, extension_points, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services from api.routers import token_events from api.routers import interface_changes @@ -123,6 +123,7 @@ app.include_router(contributions.router) app.include_router(sbom.router) app.include_router(messages.router) app.include_router(capability_requests.router) +app.include_router(suggestions.router) app.include_router(tpsc.router) app.include_router(services.router) app.include_router(token_events.router) diff --git a/api/models/__init__.py b/api/models/__init__.py index f40b01b..522c3b0 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -34,6 +34,12 @@ from api.models.workplan_launch_request import WorkplanLaunchRequest from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket from api.models.write_idempotency_key import WriteIdempotencyKey +from api.models.suggestion import ( + Suggestion, + SuggestionNote, + SuggestionRelevanceBump, + SuggestionStage, +) __all__ = [ "Base", @@ -67,4 +73,5 @@ __all__ = [ "FabricGraphImport", "FabricGraphNode", "FabricGraphEdge", "LegacyInterface", "LegacyInterfaceUsageBucket", "WriteIdempotencyKey", + "Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage", ] \ No newline at end of file diff --git a/api/models/suggestion.py b/api/models/suggestion.py new file mode 100644 index 0000000..df5a791 --- /dev/null +++ b/api/models/suggestion.py @@ -0,0 +1,120 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from api.models.base import Base, TimestampMixin, new_uuid + + +class SuggestionStage(str, enum.Enum): + suggestion = "suggestion" + requirement = "requirement" + promoted = "promoted" + declined = "declined" + + +OPEN_SUGGESTION_STAGES = (SuggestionStage.suggestion, SuggestionStage.requirement) + + +class Suggestion(Base, TimestampMixin): + __tablename__ = "suggestions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + origin: Mapped[str | None] = mapped_column(String(200), nullable=True) + origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True) + stage: Mapped[SuggestionStage] = mapped_column( + Enum(SuggestionStage, name="suggestionstage"), + nullable=False, + default=SuggestionStage.suggestion, + index=True, + ) + relevance: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + relevance_events: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + last_requested_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + base_value: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3") + job_size: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3") + relevance_weight: Mapped[float] = mapped_column( + Float, nullable=False, default=1.0, server_default="1" + ) + promoted_task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True + ) + + domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 + topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821 + promoted_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821 + notes: Mapped[list["SuggestionNote"]] = relationship( + "SuggestionNote", + back_populates="suggestion", + lazy="selectin", + order_by="SuggestionNote.created_at", + ) + + @property + def domain_slug(self) -> str: + return self.domain.slug if self.domain is not None else "" + + +class SuggestionNote(Base): + __tablename__ = "suggestion_notes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid) + suggestion_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("suggestions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + stage: Mapped[str] = mapped_column(String(30), nullable=False) + author: Mapped[str | None] = mapped_column(String(100), nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + suggestion: Mapped["Suggestion"] = relationship("Suggestion", back_populates="notes") + + +class SuggestionRelevanceBump(Base): + """Audit trail for relevance bumps; supports debounce lookups.""" + + __tablename__ = "suggestion_relevance_bumps" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid) + suggestion_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("suggestions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + source: Mapped[str] = mapped_column(String(50), nullable=False) + source_key: Mapped[str] = mapped_column(String(200), nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) \ No newline at end of file diff --git a/api/routers/capability_requests.py b/api/routers/capability_requests.py index db3df74..3192004 100644 --- a/api/routers/capability_requests.py +++ b/api/routers/capability_requests.py @@ -13,6 +13,7 @@ from api.models.capability_request import CapabilityRequest from api.models.domain import Domain from api.models.managed_repo import ManagedRepo from api.models.task import Task +from api.services.suggestion_relevance import bump_matching_for_capability_request from api.schemas.capability_request import ( CapabilityRequestAccept, CapabilityRequestCreate, @@ -68,6 +69,23 @@ def _build_capability_request( ) +async def _on_capability_request_persisted( + session: AsyncSession, + req: CapabilityRequest, + body: CapabilityRequestCreate, +) -> None: + await session.flush() + await bump_matching_for_capability_request( + session, + request_id=req.id, + title=body.title, + description=body.description or "", + capability_type=body.capability_type, + catalog_entry_id=req.catalog_entry_id, + ) + await _notify_on_create(session, req, body) + + async def _notify_on_create( session: AsyncSession, req: CapabilityRequest, @@ -405,7 +423,7 @@ router.include_router( request_read_schema=CapabilityRequestRead, route_request=_route_capability, build_request=_build_capability_request, - on_request_persisted=_notify_on_create, + on_request_persisted=_on_capability_request_persisted, check_transition=_check_transition, apply_accept_fields=_apply_accept_fields, after_accept=_notify_on_accept, diff --git a/api/routers/state.py b/api/routers/state.py index cd7fd89..0f0f218 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -21,8 +21,12 @@ from api.models.sbom_snapshot import SBOMSnapshot from api.models.task import Task, TaskPriority, TaskStatus from api.models.technical_debt import TechnicalDebt from api.models.topic import Topic, TopicStatus +from api.models.suggestion import OPEN_SUGGESTION_STAGES, Suggestion from api.models.workplan import Workplan from api.models.workplan_dependency import WorkplanDependency +from api.schemas.suggestion import RankedSuggestionDigest +from api.services.suggestion_relevance import bump_suggestions_for_next_steps +from api.services.suggestion_wsjf import compute_wsjf, suggestion_sort_key from api.schemas.decision import DecisionRead from api.schemas.domain import DomainSummary from api.schemas.progress_event import ProgressEventRead @@ -329,7 +333,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: ), ) - next_steps = await _derive_next_steps(session) + next_steps, _ = await _derive_next_steps(session) # Domain summary stats domain_summaries = await _build_domain_summaries(session) @@ -368,6 +372,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: ) )).scalar() or 0 + ranked_suggestions = await _ranked_suggestion_digest(session, limit=10) + result = StateSummary( generated_at=datetime.now(tz=timezone.utc), totals=totals, @@ -387,6 +393,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: contribution_counts=contribution_counts, licence_risk_count=licence_risk_count, open_capability_requests=open_cap_req_count, + ranked_suggestions=ranked_suggestions, open_workstreams=[ WorkstreamWithDeps( **{ @@ -700,7 +707,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: waiting_tasks=[TaskRead.model_validate(t) for t in waiting], blocked_tasks=[TaskRead.model_validate(t) for t in waiting], recent_progress=[ProgressEventRead.model_validate(e) for e in recent], - next_steps=await _derive_next_steps(session), + next_steps=(await _derive_next_steps(session))[0], contribution_counts=contribution_counts, licence_risk_count=licence_risk_count, open_capability_requests=open_cap_req_count, @@ -852,7 +859,33 @@ _PRIORITY_RANK = { } -async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: +async def _ranked_suggestion_digest( + session: AsyncSession, + *, + limit: int = 10, +) -> list[RankedSuggestionDigest]: + rows = ( + await session.execute( + select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES)) + ) + ).scalars().all() + ranked = sorted(rows, key=suggestion_sort_key)[:limit] + return [ + RankedSuggestionDigest( + id=s.id, + title=s.title, + stage=s.stage, + domain_slug=s.domain_slug, + origin_ref=s.origin_ref, + relevance=s.relevance, + wsjf=compute_wsjf(s), + last_requested_at=s.last_requested_at, + ) + for s in ranked + ] + + +async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], list[Suggestion]]: """Derive contextual next-action suggestions from current hub state. Two signal sources: @@ -991,7 +1024,31 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: )) seen_task_ids.add(task.id) - return steps + # Signal 3: open demand-weighted suggestions (needed-but-unmet backlog) + open_suggestions = sorted( + ( + await session.execute( + select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES)) + ) + ).scalars().all(), + key=suggestion_sort_key, + )[:5] + for suggestion in open_suggestions: + steps.append(NextStep( + type="open_suggestion", + domain=suggestion.domain_slug, + workstream_id=suggestion.workplan_id, + workstream_title=None, + workstream_slug=suggestion.origin_ref, + task_id=None, + task_title=None, + message=( + f"Gated need '{suggestion.title}' (stage={suggestion.stage.value}, " + f"relevance={suggestion.relevance}) — vet or promote when unblocked" + ), + )) + + return steps, open_suggestions async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None: @@ -1019,8 +1076,12 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N Returns suggestions based on: - Recently resolved decisions → first open task in the same workstream - Workstreams whose every dependency workstream is now finished -> first todo task + - Open demand-weighted suggestions (gated needs accruing relevance) """ - return await _derive_next_steps(session) + steps, surfaced = await _derive_next_steps(session) + await bump_suggestions_for_next_steps(session, surfaced) + await session.commit() + return steps @router.get("/health") diff --git a/api/routers/suggestions.py b/api/routers/suggestions.py new file mode 100644 index 0000000..b04c044 --- /dev/null +++ b/api/routers/suggestions.py @@ -0,0 +1,259 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.domain import Domain +from api.models.suggestion import ( + OPEN_SUGGESTION_STAGES, + Suggestion, + SuggestionNote, + SuggestionStage, +) +from api.models.task import Task, TaskPriority, TaskStatus +from api.schemas.suggestion import ( + SuggestionBumpRelevance, + SuggestionCreate, + SuggestionDecline, + SuggestionNoteRead, + SuggestionPromote, + SuggestionRead, + SuggestionVet, +) +from api.services.suggestion_relevance import bump_relevance +from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key +from api.task_status import normalize_task_status + +router = APIRouter(prefix="/suggestions", tags=["suggestions"]) + +_ALLOWED_VET_FROM = {SuggestionStage.suggestion} +_ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement} +_ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement} + + +async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID: + row = await session.execute( + select(Domain.id).where(Domain.slug == slug, Domain.status == "active") + ) + domain_id = row.scalar_one_or_none() + if domain_id is None: + valid = [r[0] for r in (await session.execute( + select(Domain.slug).where(Domain.status == "active") + )).all()] + raise HTTPException( + status_code=422, + detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}", + ) + return domain_id + + +def _enrich_read(suggestion: Suggestion) -> SuggestionRead: + data = SuggestionRead.model_validate(suggestion) + data.cost_of_delay = cost_of_delay(suggestion) + data.wsjf = compute_wsjf(suggestion) + return data + + +async def _get_suggestion_or_404( + suggestion_id: uuid.UUID, + session: AsyncSession, +) -> Suggestion: + suggestion = await session.get(Suggestion, suggestion_id) + if suggestion is None: + raise HTTPException(status_code=404, detail="Suggestion not found") + return suggestion + + +def _reject_stage(suggestion: Suggestion, allowed: set[SuggestionStage], action: str) -> None: + if suggestion.stage not in allowed: + raise HTTPException( + status_code=409, + detail=f"Cannot {action} suggestion in stage '{suggestion.stage.value}'", + ) + + +@router.get("/", response_model=list[SuggestionRead]) +async def list_suggestions( + domain: str | None = None, + stage: SuggestionStage | None = None, + include_terminal: bool = Query(False), + rank: str | None = Query(None), + limit: int = Query(100, ge=1, le=500), + session: AsyncSession = Depends(get_session), +) -> list[SuggestionRead]: + q = select(Suggestion) + if domain: + domain_id = await _resolve_domain_id(domain, session) + q = q.where(Suggestion.domain_id == domain_id) + if stage: + q = q.where(Suggestion.stage == stage) + elif not include_terminal: + q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES)) + result = await session.execute(q) + suggestions = list(result.scalars().all()) + if rank == "wsjf": + suggestions.sort(key=suggestion_sort_key) + else: + suggestions.sort(key=lambda s: s.created_at) + return [_enrich_read(s) for s in suggestions[:limit]] + + +@router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED) +async def create_suggestion( + body: SuggestionCreate, + session: AsyncSession = Depends(get_session), +) -> SuggestionRead: + domain_id = await _resolve_domain_id(body.domain, session) + suggestion = Suggestion( + domain_id=domain_id, + topic_id=body.topic_id, + workplan_id=body.workplan_id, + title=body.title, + description=body.description, + origin=body.origin, + origin_ref=body.origin_ref, + base_value=body.base_value, + job_size=body.job_size, + relevance_weight=body.relevance_weight, + ) + session.add(suggestion) + await session.commit() + await session.refresh(suggestion) + return _enrich_read(suggestion) + + +@router.get("/{suggestion_id}", response_model=SuggestionRead) +async def get_suggestion( + suggestion_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> SuggestionRead: + suggestion = await _get_suggestion_or_404(suggestion_id, session) + return _enrich_read(suggestion) + + +@router.post("/{suggestion_id}/vet", response_model=SuggestionRead) +async def vet_suggestion( + suggestion_id: uuid.UUID, + body: SuggestionVet, + session: AsyncSession = Depends(get_session), +) -> SuggestionRead: + suggestion = await _get_suggestion_or_404(suggestion_id, session) + _reject_stage(suggestion, _ALLOWED_VET_FROM, "vet") + suggestion.stage = SuggestionStage.requirement + if body.base_value is not None: + suggestion.base_value = body.base_value + if body.job_size is not None: + suggestion.job_size = body.job_size + if body.relevance_weight is not None: + suggestion.relevance_weight = body.relevance_weight + if body.workplan_id is not None: + suggestion.workplan_id = body.workplan_id + session.add( + SuggestionNote( + suggestion_id=suggestion.id, + stage=SuggestionStage.requirement.value, + author=body.author, + content=body.note, + ) + ) + await session.commit() + await session.refresh(suggestion) + return _enrich_read(suggestion) + + +@router.post("/{suggestion_id}/decline", response_model=SuggestionRead) +async def decline_suggestion( + suggestion_id: uuid.UUID, + body: SuggestionDecline, + session: AsyncSession = Depends(get_session), +) -> SuggestionRead: + suggestion = await _get_suggestion_or_404(suggestion_id, session) + _reject_stage(suggestion, _ALLOWED_DECLINE_FROM, "decline") + suggestion.stage = SuggestionStage.declined + session.add( + SuggestionNote( + suggestion_id=suggestion.id, + stage=SuggestionStage.declined.value, + author=body.author, + content=body.note, + ) + ) + await session.commit() + await session.refresh(suggestion) + return _enrich_read(suggestion) + + +@router.post("/{suggestion_id}/promote", response_model=SuggestionRead) +async def promote_suggestion_to_task( + suggestion_id: uuid.UUID, + body: SuggestionPromote, + session: AsyncSession = Depends(get_session), +) -> SuggestionRead: + suggestion = await _get_suggestion_or_404(suggestion_id, session) + _reject_stage(suggestion, _ALLOWED_PROMOTE_FROM, "promote") + if suggestion.workplan_id is None: + raise HTTPException( + status_code=409, + detail="Suggestion must have workplan_id before promotion", + ) + + task = Task( + workplan_id=suggestion.workplan_id, + title=body.task_title or suggestion.title, + description=body.task_description or suggestion.description, + status=TaskStatus(normalize_task_status(body.task_status)), + priority=TaskPriority(body.task_priority), + ) + session.add(task) + await session.flush() + + suggestion.stage = SuggestionStage.promoted + suggestion.promoted_task_id = task.id + if body.note: + session.add( + SuggestionNote( + suggestion_id=suggestion.id, + stage=SuggestionStage.promoted.value, + author=body.author, + content=body.note, + ) + ) + await session.commit() + await session.refresh(suggestion) + return _enrich_read(suggestion) + + +@router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead) +async def bump_suggestion_relevance( + suggestion_id: uuid.UUID, + body: SuggestionBumpRelevance, + session: AsyncSession = Depends(get_session), +) -> SuggestionRead: + suggestion = await _get_suggestion_or_404(suggestion_id, session) + source_key = body.author or "explicit" + await bump_relevance( + session, + suggestion, + source="explicit", + source_key=source_key, + reason=body.reason, + ) + await session.commit() + await session.refresh(suggestion) + return _enrich_read(suggestion) + + +@router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead]) +async def list_suggestion_notes( + suggestion_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> list[SuggestionNote]: + await _get_suggestion_or_404(suggestion_id, session) + result = await session.execute( + select(SuggestionNote) + .where(SuggestionNote.suggestion_id == suggestion_id) + .order_by(SuggestionNote.created_at) + ) + return list(result.scalars().all()) \ No newline at end of file diff --git a/api/schemas/state.py b/api/schemas/state.py index 6132dca..e7a7eee 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -9,6 +9,7 @@ from api.schemas.domain import DomainSummary from api.schemas.progress_event import ProgressEventRead from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams +from api.schemas.suggestion import RankedSuggestionDigest from api.schemas.workstream import WorkstreamWithDeps @@ -85,6 +86,7 @@ class StateSummary(BaseModel): contribution_counts: dict[str, int] = {} licence_risk_count: int = 0 open_capability_requests: int = 0 + ranked_suggestions: list[RankedSuggestionDigest] = [] class DashboardWorkplanRow(BaseModel): diff --git a/api/schemas/suggestion.py b/api/schemas/suggestion.py new file mode 100644 index 0000000..64cc1f1 --- /dev/null +++ b/api/schemas/suggestion.py @@ -0,0 +1,96 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from api.models.suggestion import SuggestionStage + + +class SuggestionNoteRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + suggestion_id: uuid.UUID + stage: str + author: str | None = None + content: str + created_at: datetime + + +class SuggestionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + domain_id: uuid.UUID + domain_slug: str = "" + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + title: str + description: str | None = None + origin: str | None = None + origin_ref: str | None = None + stage: SuggestionStage + relevance: int + relevance_events: int + last_requested_at: datetime | None = None + base_value: float + job_size: float + relevance_weight: float + promoted_task_id: uuid.UUID | None = None + cost_of_delay: float | None = None + wsjf: float | None = None + created_at: datetime + updated_at: datetime + notes: list[SuggestionNoteRead] = Field(default_factory=list) + + +class SuggestionCreate(BaseModel): + domain: str + title: str + description: str | None = None + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + origin: str | None = None + origin_ref: str | None = None + base_value: float = 3.0 + job_size: float = 3.0 + relevance_weight: float = 1.0 + + +class SuggestionVet(BaseModel): + author: str | None = None + note: str + base_value: float | None = None + job_size: float | None = None + relevance_weight: float | None = None + workplan_id: uuid.UUID | None = None + + +class SuggestionDecline(BaseModel): + author: str | None = None + note: str + + +class SuggestionPromote(BaseModel): + author: str | None = None + note: str | None = None + task_title: str | None = None + task_description: str | None = None + task_priority: str = "medium" + task_status: str = "wait" + + +class SuggestionBumpRelevance(BaseModel): + reason: str | None = None + author: str | None = None + + +class RankedSuggestionDigest(BaseModel): + id: uuid.UUID + title: str + stage: SuggestionStage + domain_slug: str + origin_ref: str | None = None + relevance: int + wsjf: float + last_requested_at: datetime | None = None \ No newline at end of file diff --git a/api/services/suggestion_relevance.py b/api/services/suggestion_relevance.py new file mode 100644 index 0000000..4855dca --- /dev/null +++ b/api/services/suggestion_relevance.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.models.suggestion import ( + OPEN_SUGGESTION_STAGES, + Suggestion, + SuggestionRelevanceBump, + SuggestionStage, +) + +DEBOUNCE_SECONDS = 3600 + + +async def bump_relevance( + session: AsyncSession, + suggestion: Suggestion, + *, + source: str, + source_key: str, + reason: str | None = None, +) -> bool: + """Increment relevance when a suggestion is needed-but-unmet. + + Returns True when the counter was incremented, False when debounced. + """ + if suggestion.stage not in OPEN_SUGGESTION_STAGES: + return False + + cutoff = datetime.now(tz=timezone.utc) - timedelta(seconds=DEBOUNCE_SECONDS) + existing = ( + await session.execute( + select(SuggestionRelevanceBump.id) + .where(SuggestionRelevanceBump.suggestion_id == suggestion.id) + .where(SuggestionRelevanceBump.source == source) + .where(SuggestionRelevanceBump.source_key == source_key) + .where(SuggestionRelevanceBump.created_at >= cutoff) + .limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + return False + + now = datetime.now(tz=timezone.utc) + suggestion.relevance += 1 + suggestion.relevance_events += 1 + suggestion.last_requested_at = now + session.add( + SuggestionRelevanceBump( + suggestion_id=suggestion.id, + source=source, + source_key=source_key, + reason=reason, + ) + ) + return True + + +async def bump_matching_for_capability_request( + session: AsyncSession, + *, + request_id: uuid.UUID, + title: str, + description: str, + capability_type: str, + catalog_entry_id: uuid.UUID | None = None, +) -> int: + """Bump open suggestions that match an unfulfilled capability need.""" + rows = ( + await session.execute( + select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES)) + ) + ).scalars().all() + + haystack = f"{title} {description} {capability_type}".lower() + bumped = 0 + for suggestion in rows: + matched = False + if suggestion.origin_ref: + ref = suggestion.origin_ref.lower() + if ref in haystack or any(token in haystack for token in ref.split("-") if len(token) > 4): + matched = True + if not matched and suggestion.title.lower() in haystack: + matched = True + if not matched: + continue + if await bump_relevance( + session, + suggestion, + source="capability_request", + source_key=str(request_id), + reason=f"Capability request matched: {title}", + ): + bumped += 1 + return bumped + + +async def bump_suggestions_for_next_steps( + session: AsyncSession, + suggestions: list[Suggestion], +) -> int: + """Bump only suggestions surfaced in a next-steps response.""" + bucket = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H") + bumped = 0 + for suggestion in suggestions: + if await bump_relevance( + session, + suggestion, + source="next_steps", + source_key=bucket, + reason="Surfaced during get_next_steps lookup", + ): + bumped += 1 + return bumped \ No newline at end of file diff --git a/api/services/suggestion_wsjf.py b/api/services/suggestion_wsjf.py new file mode 100644 index 0000000..39df65d --- /dev/null +++ b/api/services/suggestion_wsjf.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from api.models.suggestion import Suggestion + + +def cost_of_delay(suggestion: Suggestion) -> float: + return suggestion.base_value + (suggestion.relevance_weight * suggestion.relevance) + + +def compute_wsjf(suggestion: Suggestion) -> float: + size = suggestion.job_size if suggestion.job_size > 0 else 3.0 + return round(cost_of_delay(suggestion) / size, 2) + + +def suggestion_sort_key(suggestion: Suggestion) -> tuple[float, int, str]: + """Higher WSJF first; tie-break on relevance then title.""" + return (-compute_wsjf(suggestion), -suggestion.relevance, suggestion.title.lower()) \ No newline at end of file diff --git a/api/services/write_idempotency.py b/api/services/write_idempotency.py index a7c06b5..fb0c646 100644 --- a/api/services/write_idempotency.py +++ b/api/services/write_idempotency.py @@ -45,6 +45,11 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = ( WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"), WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"), WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"), + WriteRouteRule("POST", r"/suggestions", "append", "create suggestion"), + WriteRouteRule("POST", r"/suggestions/[^/]+/vet", "replace", "vet suggestion"), + WriteRouteRule("POST", r"/suggestions/[^/]+/decline", "replace", "decline suggestion"), + WriteRouteRule("POST", r"/suggestions/[^/]+/promote", "replace", "promote suggestion to task"), + WriteRouteRule("POST", r"/suggestions/[^/]+/bump-relevance", "append", "bump suggestion relevance"), ) diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index d885655..c675b98 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -81,6 +81,7 @@ export default { { name: "Interventions", path: "/interventions" }, { name: "Tasks", path: "/tasks" }, { name: "UI Feedback", path: "/ui-feedback" }, + { name: "Suggestions", path: "/suggestions" }, { name: "WSJF Triage", path: "/wsjf-triage" }, ], }, @@ -122,6 +123,7 @@ export default { { name: "Workstream Health", path: "/docs/workstream-health-index" }, { name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" }, { name: "Workstreams", path: "/docs/workstreams" }, + { name: "Suggestions", path: "/docs/suggestions" }, { name: "WSJF Triage", path: "/docs/wsjf-triage" }, ], }, diff --git a/dashboard/src/docs/capabilities.md b/dashboard/src/docs/capabilities.md index 2f05a74..11f57df 100644 --- a/dashboard/src/docs/capabilities.md +++ b/dashboard/src/docs/capabilities.md @@ -227,5 +227,8 @@ and age in days. --- *Capability requests are a sanctioned write use case of the State Hub alongside -`resolve_decision` and `get_next_steps`. They do not originate in workplan files — +`resolve_decision`, `get_next_steps`, and the suggestion backlog writes +(`create_suggestion`, `vet_suggestion`, `decline_suggestion`, +`promote_suggestion_to_task`, `bump_suggestion_relevance`). They do not +originate in workplan files — they are operational coordination.* diff --git a/dashboard/src/docs/suggestions.md b/dashboard/src/docs/suggestions.md new file mode 100644 index 0000000..a512418 --- /dev/null +++ b/dashboard/src/docs/suggestions.md @@ -0,0 +1,49 @@ +# Demand-Weighted Suggestion Backlog + +The `/suggestions` page shows persisted **gated needs** that are not yet real +tasks. Each unmet lookup increments `relevance`, which raises WSJF ranking. + +## Stages + +| Stage | Meaning | +|-------|---------| +| `suggestion` | Recorded need, not yet vetted | +| `requirement` | Vetted with structured fields and notes | +| `promoted` | Became a real `Task` (`promoted_task_id` set) | +| `declined` | Rejected; terminal | + +## WSJF projection + +```text +cost_of_delay = base_value + (relevance_weight × relevance) +wsjf = cost_of_delay / job_size +``` + +`GET /suggestions?rank=wsjf` returns open suggestions/requirements ordered by +score. Promoted and declined entries are excluded unless +`include_terminal=true`. + +## Sanctioned writes + +MCP and REST: + +- `create_suggestion` / `POST /suggestions/` +- `vet_suggestion` / `POST /suggestions/{id}/vet` +- `decline_suggestion` / `POST /suggestions/{id}/decline` +- `promote_suggestion_to_task` / `POST /suggestions/{id}/promote` +- `bump_suggestion_relevance` / `POST /suggestions/{id}/bump-relevance` + +Relevance also bumps automatically when: + +- `GET /state/next_steps` surfaces open suggestions +- A `CapabilityRequest` matches an open suggestion + +## Daily triage + +`GET /state/summary` includes `ranked_suggestions` for the activity-core +`daily_triage_digest` resolver. See [WSJF Triage](/docs/wsjf-triage). + +## Origin + +Motivated by ops-warden `WARDEN-WP-0012` gated routing scenarios. Example +backfill: `scripts/seed_wp0012_suggestions.py`. \ No newline at end of file diff --git a/dashboard/src/suggestions.md b/dashboard/src/suggestions.md new file mode 100644 index 0000000..291385c --- /dev/null +++ b/dashboard/src/suggestions.md @@ -0,0 +1,79 @@ +--- +title: Suggestions +--- + +```js +import {apiFetch, pollDelay, waitForVisible} from "./components/config.js"; +const POLL = 30_000; +``` + +```js +const sugState = (async function*() { + let failures = 0; + while (true) { + let data = [], ok = false; + try { + const r = await apiFetch("/suggestions/?rank=wsjf&limit=100"); + ok = r.ok; + data = ok ? await r.json() : []; + } catch {} + failures = ok ? 0 : failures + 1; + yield {data, ok, ts: new Date()}; + await waitForVisible(pollDelay({ok, base: POLL, failures})); + } +})(); +``` + +```js +const suggestions = sugState.data ?? []; +const _ok = sugState.ok ?? false; +const _ts = sugState.ts; +``` + +# Demand-Weighted Suggestions + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +const _liveEl = html`
+ + ${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`API offline`} +
`; +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/suggestions"); } + +display(html`

Ranked by WSJF = (base_value + relevance_weight × relevance) / job_size. Gated needs accrue relevance when unmet.

`); +display(html`

Daily WSJF triage consumes this backlog in its digest.

`); +``` + +```js +const stageBadge = (stage) => { + const colors = { + suggestion: "#6b7280", + requirement: "#2563eb", + promoted: "#059669", + declined: "#9ca3af", + }; + return html`${stage}`; +}; + +const rows = suggestions.map((s) => html` + ${stageBadge(s.stage)} + ${s.title}${s.origin_ref ? html`
${s.origin_ref}` : ""} + ${s.domain_slug || "—"} + ${s.relevance} + ${s.wsjf?.toFixed?.(1) ?? s.wsjf} + ${s.last_requested_at ? new Date(s.last_requested_at).toLocaleString() : "—"} +`); + +display(html` + + + + ${rows.length ? rows : html``} +
StageTitleDomainRelevanceWSJFLast requested
No open suggestions yet.
`); +``` \ No newline at end of file diff --git a/dashboard/src/wsjf-triage.md b/dashboard/src/wsjf-triage.md index 7debe86..a1569f2 100644 --- a/dashboard/src/wsjf-triage.md +++ b/dashboard/src/wsjf-triage.md @@ -219,7 +219,7 @@ injectTocTop("live-indicator", _liveEl); const _h1 = document.querySelector("#observablehq-main h1"); if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/wsjf-triage"); } -display(html`

Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on.

`); +display(html`

Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on. Ranked suggestion backlog feeds the digest.

`); display(html`
Last updated ${latestReport ? fmtDateTime(latestReport.created_at) : "No daily_triage events yet"} diff --git a/docs/activity-core-delegation.md b/docs/activity-core-delegation.md index 960b2db..ada13da 100644 --- a/docs/activity-core-delegation.md +++ b/docs/activity-core-delegation.md @@ -67,7 +67,7 @@ unset. - DB schema + Alembic migrations - API endpoints (CRUD + status transitions + read-model queries) - MCP tools (read + sanctioned writes: `resolve_decision`, - `add_progress_event`, `get_next_steps`) + `add_progress_event`, `get_next_steps`, suggestion backlog writes) - The consistency engine (`scripts/consistency_check.py`) — it owns ADR-001 reconciliation between workplan files and the DB. - The `cleanup_stale_tasks.py` *script* (not its schedule) — it owns diff --git a/docs/cluster-operating-model.md b/docs/cluster-operating-model.md new file mode 100644 index 0000000..b7f794e --- /dev/null +++ b/docs/cluster-operating-model.md @@ -0,0 +1,200 @@ +# State Hub Cluster Operating Model + +This document describes how State Hub runs after the pragmatic cluster migration +(`CUST-WP-0011`). It is the operator runbook for day-to-day use, rollback, and +known pragmatic limitations. + +## Runtime Summary + +| Component | Location | Notes | +|-----------|----------|-------| +| API workload | `coulombcore-k3s`, namespace `state-hub` | Single-replica Deployment | +| Database | CNPG cluster `state-hub-db`, namespace `databases` | One instance, healthy | +| Image registry | `gitea.coulomb.social/coulomb/state-hub` | Tag pinned in Helm values | +| Primary access | `http://127.0.0.1:8000` | ops-bridge `state-hub-primary` forward tunnel | +| WSL2 fallback | `make api` + local Docker Postgres | Retained; not the normal writer | + +State Hub is **not** publicly exposed. Access stays on the private tunnel / +ops-bridge path. + +Deployment handoff assets live under `deploy/railiance/` and were promoted to +the coulombcore cluster during cutover (2026-07-03). + +## How Agents Reach State Hub + +### Primary operator workstation (WSL2) + +The cluster API is the production writer. Port `8000` on the workstation is +forwarded to the cluster service through ops-bridge: + +```bash +bridge status # state-hub-primary should be connected +curl -fsS http://127.0.0.1:8000/state/health +``` + +Local MCP registration (default): + +```bash +make register-mcp +make mcp-http # SSE on :8001 +``` + +### Remote machines (Railiance01, CoulombCore, Haskelseed, …) + +Bring up the managed tunnel mesh, then register MCP against the remote API port: + +```bash +make bridges +make register-mcp MCP_URL=http://127.0.0.1:18001/sse API_BASE=http://127.0.0.1:18000 +``` + +Restart the agent runtime after MCP registration. + +Onboarding details: [`docs/onboarding.md`](onboarding.md). + +### Claude Code / Codex session start + +```bash +cat .custodian-brief.md +curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool +``` + +When MCP tools are available, prefer `get_domain_summary("infotech")` or +equivalent State Hub MCP helpers. + +## Backups and Restores + +### Cluster database (CNPG) + +The `state-hub-db` cluster is managed by CloudNativePG on coulombcore-k3s. +Scheduled CNPG backups are **not yet configured** — treat manual dumps as the +current backup path until `CUST-WP-0038` or a disaster-control workplan adds +automated retention. + +Manual cluster dump (operator): + +```bash +# Port-forward the rw service, then pg_dump from an operator shell +KUBECONFIG=~/.kube/config kubectl port-forward -n databases svc/state-hub-db-rw 15432:5432 +pg_dump -h 127.0.0.1 -p 15432 -U state_hub -Fc state_hub > state-hub-$(date +%Y%m%d).dump +``` + +Restore into an isolated test database before any production restore attempt. +The T01 drill (2026-05-02) proved the WSL2 dump/restore path; repeat that +discipline before any live restore. + +### WSL2 fallback database + +The legacy Docker Postgres (`infra-postgres-1`) remains available for rollback. +It is **not** receiving normal writes after cutover. + +To take a WSL2 snapshot while fallback is stopped: + +```bash +docker exec infra-postgres-1 pg_dump -U custodian -Fc custodian > wsl2-state-hub.dump +``` + +## Roll Back to WSL2 + +Use this when the cluster deployment is unhealthy and operators need the last +known-good local writer. + +1. Stop forwarding the primary tunnel: + + ```bash + bridge down state-hub-primary + ``` + +2. Start the local stack: + + ```bash + cd ~/state-hub + make api + ``` + +3. Verify local health: + + ```bash + curl -fsS http://127.0.0.1:8000/state/health + ``` + +4. Re-register MCP if needed (`make register-mcp` without tunnel overrides). + +5. Record a progress event documenting the rollback and the triggering incident. + +Returning to cluster-primary: + +```bash +bridge up state-hub-primary +# stop local uvicorn if it would conflict on :8000 +fuser -k 8000/tcp 2>/dev/null || true +curl -fsS http://127.0.0.1:8000/state/health +``` + +Cutover sequence reference: `CUST-WP-0011-T07` (2026-07-03). + +## Consistency Sync + +File-backed workplans remain authoritative (ADR-001). After commits: + +```bash +make fix-consistency REPO= +# or from repo root: +make fix-consistency-here +``` + +The 15-minute all-repo sweep is owned by activity-core on Railiance01. It +reaches the API through the `actcore-state-hub-bridge` proxy chain. Manual +invocation from the workstation still works: + +```bash +curl -s -X POST http://127.0.0.1:8000/consistency/sweep/remote-all \ + -H "Content-Type: application/json" \ + -d '{"max_seconds": 300}' | python3 -m json.tool +``` + +Runbook: [`docs/consistency-sweep-runbook.md`](consistency-sweep-runbook.md). + +**Known gap:** scheduled activity-core sweeps paused after the 2026-07-03 +cutover while the bridge target chain was rewired. Manual sweeps succeed. +Re-enablement is tracked outside this workplan (service-inventory gap). + +## Pragmatic Limitations (Single-Node) + +This deployment is intentionally **not** highly available: + +- One API replica on one k3s node. +- One CNPG instance (no synchronous replica). +- No public ingress; tunnel dependency for all remote access. +- Cluster and tunnel outages require the WSL2 fallback or the offline write + buffer (`docs/offline-write-buffer.md`). + +Long-term HA, replicated storage, tested failover, and WSL2 retirement belong +to **`CUST-WP-0038`**. + +## WSL2 Retirement + +Do **not** retire the WSL2 State Hub instance in normal operations. It remains +the disaster-recovery fallback until `CUST-WP-0038` (or a separate human +decision) explicitly approves retirement. + +## Operator Checklist + +Daily or after infra changes: + +```bash +bridge check +curl -fsS http://127.0.0.1:8000/state/health +KUBECONFIG=~/.kube/config kubectl get pods -n state-hub +KUBECONFIG=~/.kube/config kubectl get cluster -n databases state-hub-db +``` + +After image or chart changes, see `deploy/railiance/README.md` and +`docs/container-image.md`. + +## References + +- `workplans/CUST-WP-0011-state-hub-threephoenix-migration.md` — migration plan +- `workplans/CUST-WP-0038-state-hub-threephoenix-ha.md` — future HA target +- `deploy/railiance/README.md` — Helm/CNPG handoff +- `the-custodian/ops/service-inventory.yml` — live endpoint inventory \ No newline at end of file diff --git a/docs/onboarding.md b/docs/onboarding.md index f39e6b5..34087b6 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -79,19 +79,24 @@ Restart Claude Code after MCP registration. - `tegwick@92.205.62.239` for Railiance01 - `tegwick@92.205.130.254` for CoulombCore -5. Start or connect to State Hub: +5. Connect to State Hub: + + Primary operators reach the cluster deployment on port `8000` through + ops-bridge (`state-hub-primary`). Verify before starting a local API: ```bash - make api + curl -fsS http://127.0.0.1:8000/state/health || make api make mcp-http ``` - If the hub is remote, use ops-bridge: + Remote machines use the tunnel mesh: ```bash make bridges ``` + Operating model: [`docs/cluster-operating-model.md`](cluster-operating-model.md) + 6. Restart Claude Code and verify that `state-hub` appears in the MCP server list. In the first session, call `get_state_summary()` when MCP tools are available. If not, use: diff --git a/infra/README.md b/infra/README.md index e583aff..affb77f 100644 --- a/infra/README.md +++ b/infra/README.md @@ -26,9 +26,14 @@ Operator runbook: [`docs/consistency-sweep-runbook.md`](../docs/consistency-swee **Prerequisites for cluster-triggered sweeps:** -- Workstation State Hub API running (`make api` or equivalent) -- `state-hub-railiance01` ops-bridge tunnel `connected` -- Workstation awake (execution still runs locally; only scheduling moved) +- Primary State Hub API reachable at `http://127.0.0.1:8000` (cluster via + `state-hub-primary` tunnel, or local `make api` during rollback) +- `state-hub-railiance01` ops-bridge tunnel `connected` for Railiance01 + activity-core triggers +- Workstation awake when sweep writebacks target local repo paths + +See [`docs/cluster-operating-model.md`](../docs/cluster-operating-model.md) for +the post-migration access model. Per-repo git post-commit hooks remain the immediate consistency path after each commit. The 15-minute sweep is belt-and-suspenders across all registered diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 632c5c9..989a3a2 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -7,12 +7,13 @@ Quick reference for all tools and resources. The State Hub is a **read model**. It observes and visualises cross-domain state that originates in the projects themselves. -Two write operations are permanently sanctioned: +Sanctioned writes (cross-cutting coordination — not bootstrap-only): | Use Case | Tools | |---|---| -| **Resolving Decisions** | `resolve_decision()` — decisions are cross-cutting; resolution must propagate across all domains | -| **Suggesting Next Steps** | `get_next_steps()` *(v0.2)* — surface what is unblocked; the domain does the work | +| **Resolving Decisions** | `resolve_decision()` | +| **Next Steps + demand signals** | `get_next_steps()` — derived steps; bumps relevance on surfaced open suggestions | +| **Suggestion backlog** | `create_suggestion()`, `vet_suggestion()`, `decline_suggestion()`, `promote_suggestion_to_task()`, `bump_suggestion_relevance()` | All other mutate tools are **bootstrap-only**: use them during First Session Protocol to give a freshly-registered project its initial workstream structure. diff --git a/mcp_server/server.py b/mcp_server/server.py index 4287d37..5ee3aec 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -1139,12 +1139,103 @@ def get_next_steps() -> str: Each suggestion includes domain, workstream, task, and a plain-language message. The hub surfaces *what* and *where* — the domain owns *how*. - This is one of the two sanctioned write-side use cases of the State Hub - (the other is resolve_decision). Suggestions are derived, not persisted. + Derived next steps may include open demand-weighted suggestions from the + persisted suggestion backlog (STATE-WP-0061). """ return json.dumps(_get("/state/next_steps"), indent=2) +# --------------------------------------------------------------------------- +# Demand-weighted suggestion backlog (STATE-WP-0061) +# --------------------------------------------------------------------------- + +@mcp.tool() +def list_suggestions( + domain: str | None = None, + stage: str | None = None, + rank: str | None = None, + limit: int = 50, +) -> str: + """List persisted suggestions, optionally ranked by WSJF.""" + return json.dumps( + _get("/suggestions", { + "domain": domain, + "stage": stage, + "rank": rank, + "limit": limit, + }), + indent=2, + ) + + +@mcp.tool() +def create_suggestion( + domain: str, + title: str, + description: str | None = None, + origin_ref: str | None = None, + workplan_id: str | None = None, + base_value: float = 3.0, + job_size: float = 3.0, +) -> str: + """Record a gated need as a relevance-accruing suggestion.""" + return json.dumps(_post("/suggestions", { + "domain": domain, + "title": title, + "description": description, + "origin_ref": origin_ref, + "workplan_id": workplan_id, + "base_value": base_value, + "job_size": job_size, + }), indent=2) + + +@mcp.tool() +def vet_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str: + """Promote a suggestion to a vetted requirement with an append-only note.""" + return json.dumps(_post(f"/suggestions/{suggestion_id}/vet", { + "note": note, + "author": author, + }), indent=2) + + +@mcp.tool() +def decline_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str: + """Decline a suggestion or requirement.""" + return json.dumps(_post(f"/suggestions/{suggestion_id}/decline", { + "note": note, + "author": author, + }), indent=2) + + +@mcp.tool() +def promote_suggestion_to_task( + suggestion_id: str, + note: str | None = None, + task_title: str | None = None, + author: str | None = None, +) -> str: + """Promote a vetted requirement into a real Task.""" + return json.dumps(_post(f"/suggestions/{suggestion_id}/promote", { + "note": note, + "task_title": task_title, + "author": author, + }), indent=2) + + +@mcp.tool() +def bump_suggestion_relevance( + suggestion_id: str, + reason: str | None = None, + author: str | None = None, +) -> str: + """Explicitly bump demand relevance when an agent hits an unmet gated need.""" + return json.dumps(_post(f"/suggestions/{suggestion_id}/bump-relevance", { + "reason": reason, + "author": author, + }), indent=2) + + # --------------------------------------------------------------------------- # Dependency graph tools (S1.4) # --------------------------------------------------------------------------- diff --git a/migrations/versions/f0a1b2c3d4e5_suggestions.py b/migrations/versions/f0a1b2c3d4e5_suggestions.py new file mode 100644 index 0000000..eb5d956 --- /dev/null +++ b/migrations/versions/f0a1b2c3d4e5_suggestions.py @@ -0,0 +1,82 @@ +"""add suggestions demand-weighted backlog + +Revision ID: f0a1b2c3d4e5 +Revises: e9f0a1b2c3d4 +Create Date: 2026-07-06 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.dialects.postgresql import UUID + +revision = "f0a1b2c3d4e5" +down_revision = "f1a2b3c4d5e6" +branch_labels = None +depends_on = None + +suggestionstage = postgresql.ENUM( + "suggestion", + "requirement", + "promoted", + "declined", + name="suggestionstage", + create_type=False, +) + + +def upgrade() -> None: + suggestionstage.create(op.get_bind(), checkfirst=True) + op.create_table( + "suggestions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("domain_id", UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False), + sa.Column("topic_id", UUID(as_uuid=True), sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True), + sa.Column("workplan_id", UUID(as_uuid=True), sa.ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("origin", sa.String(length=200), nullable=True), + sa.Column("origin_ref", sa.String(length=200), nullable=True), + sa.Column("stage", suggestionstage, nullable=False, server_default="suggestion"), + sa.Column("relevance", sa.Integer(), nullable=False, server_default="0"), + sa.Column("relevance_events", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_requested_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("base_value", sa.Float(), nullable=False, server_default="3"), + sa.Column("job_size", sa.Float(), nullable=False, server_default="3"), + sa.Column("relevance_weight", sa.Float(), nullable=False, server_default="1"), + sa.Column("promoted_task_id", UUID(as_uuid=True), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_suggestions_domain_id", "suggestions", ["domain_id"]) + op.create_index("ix_suggestions_stage", "suggestions", ["stage"]) + op.create_index("ix_suggestions_origin_ref", "suggestions", ["origin_ref"]) + + op.create_table( + "suggestion_notes", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("suggestion_id", UUID(as_uuid=True), sa.ForeignKey("suggestions.id", ondelete="CASCADE"), nullable=False), + sa.Column("stage", sa.String(length=30), nullable=False), + sa.Column("author", sa.String(length=100), nullable=True), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_suggestion_notes_suggestion_id", "suggestion_notes", ["suggestion_id"]) + + op.create_table( + "suggestion_relevance_bumps", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("suggestion_id", UUID(as_uuid=True), sa.ForeignKey("suggestions.id", ondelete="CASCADE"), nullable=False), + sa.Column("source", sa.String(length=50), nullable=False), + sa.Column("source_key", sa.String(length=200), nullable=False), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_suggestion_relevance_bumps_suggestion_id", "suggestion_relevance_bumps", ["suggestion_id"]) + + +def downgrade() -> None: + op.drop_table("suggestion_relevance_bumps") + op.drop_table("suggestion_notes") + op.drop_table("suggestions") + op.execute("DROP TYPE IF EXISTS suggestionstage") \ No newline at end of file diff --git a/scripts/seed_wp0012_suggestions.py b/scripts/seed_wp0012_suggestions.py new file mode 100644 index 0000000..dd00268 --- /dev/null +++ b/scripts/seed_wp0012_suggestions.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Backfill WARDEN-WP-0012 gated routing scenarios as demand-weighted suggestions.""" +from __future__ import annotations + +import os +import sys + +import httpx + +API_BASE = os.getenv("STATE_HUB_URL", "http://127.0.0.1:8000") + +SCENARIOS = [ + ("issue-core-ingestion-api-key", "Issue-core ingestion API key OpenBao path"), + ("activity-core-issue-sink", "Activity-core issue sink consumer key custody"), + ("openrouter-llm-connect", "OpenRouter llm-connect OpenBao → K8s Secret path"), + ("object-storage-sts", "Object storage STS vending path (NK-WP-0007)"), + ("human-oidc-login", "Human OIDC login via key-cape / Keycloak"), + ("flex-auth-resource-check", "flex-auth policy decision before sensitive action"), + ("host-principal-deploy", "auth_principals sync for host principal deploy"), +] + + +def main() -> int: + created = 0 + with httpx.Client(base_url=API_BASE, timeout=30.0) as client: + health = client.get("/state/health") + health.raise_for_status() + existing = { + item.get("origin_ref") + for item in client.get("/suggestions/", params={"include_terminal": True}).json() + } + for origin_ref, title in SCENARIOS: + if origin_ref in existing: + continue + resp = client.post("/suggestions/", json={ + "domain": os.getenv("SUGGESTION_DOMAIN", "infotech"), + "title": title, + "description": ( + "Gated routing scenario from WARDEN-WP-0012. Owner path not yet " + "shipped; accrues relevance when agents hit this unmet need." + ), + "origin": "WARDEN-WP-0012", + "origin_ref": origin_ref, + "base_value": 4.0, + "job_size": 3.0, + }) + resp.raise_for_status() + created += 1 + print(f"created {origin_ref}") + print(f"done: {created} new suggestions") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/tests/test_suggestions.py b/tests/test_suggestions.py new file mode 100644 index 0000000..54a3b47 --- /dev/null +++ b/tests/test_suggestions.py @@ -0,0 +1,158 @@ +"""Demand-weighted suggestion backlog tests (STATE-WP-0061).""" +from __future__ import annotations + +import pytest + +from tests.conftest import create_test_repo, create_test_workplan +from tests.test_capability_requests import _create_domain, _create_topic + + +async def _create_suggestion(client, **kwargs): + payload = { + "domain": "custodian", + "title": "Issue-core ingestion API key path", + "description": "OpenBao KV path for issue-core ingestion", + "origin_ref": "issue-core-ingestion-api-key", + "base_value": 4.0, + "job_size": 2.0, + } + payload.update(kwargs) + r = await client.post("/suggestions/", json=payload) + assert r.status_code == 201, r.text + return r.json() + + +@pytest.mark.asyncio +async def test_create_list_and_wsjf_ranking(client): + await _create_domain(client, "custodian", "Custodian") + low = await _create_suggestion( + client, + title="Low priority path", + origin_ref="low-priority", + base_value=1.0, + job_size=5.0, + ) + high = await _create_suggestion( + client, + title="High priority path", + origin_ref="high-priority", + base_value=5.0, + job_size=1.0, + ) + + r = await client.get("/suggestions/?rank=wsjf") + assert r.status_code == 200 + ranked = r.json() + assert ranked[0]["id"] == high["id"] + assert ranked[0]["wsjf"] > ranked[1]["wsjf"] + + await client.post( + f"/suggestions/{low['id']}/bump-relevance", + json={"reason": "hit again", "author": "agent-a"}, + ) + await client.post( + f"/suggestions/{low['id']}/bump-relevance", + json={"reason": "hit again", "author": "agent-b"}, + ) + r2 = await client.get(f"/suggestions/{low['id']}") + assert r2.json()["relevance"] == 2 + + +@pytest.mark.asyncio +async def test_bump_relevance_debounces_duplicate_explicit_bumps(client): + await _create_domain(client, "custodian", "Custodian") + suggestion = await _create_suggestion(client) + first = await client.post( + f"/suggestions/{suggestion['id']}/bump-relevance", + json={"reason": "routing gap", "author": "codex"}, + ) + second = await client.post( + f"/suggestions/{suggestion['id']}/bump-relevance", + json={"reason": "routing gap", "author": "codex"}, + ) + assert first.status_code == 200 + assert second.status_code == 200 + refreshed = await client.get(f"/suggestions/{suggestion['id']}") + assert refreshed.json()["relevance"] == 1 + + +@pytest.mark.asyncio +async def test_vet_decline_and_promote_flow(client): + await _create_domain(client, "custodian", "Custodian") + topic = await _create_topic(client, "custodian") + repo = await create_test_repo(client, domain_slug="custodian", slug="state-hub") + workplan = await create_test_workplan( + client, repo_id=repo["id"], topic_id=topic["id"], slug="state-wp-0061", title="WP-0061", + ) + + suggestion = await _create_suggestion( + client, + workplan_id=workplan["id"], + title="Promotable gated need", + origin_ref="promote-me", + ) + + vet = await client.post( + f"/suggestions/{suggestion['id']}/vet", + json={"note": "Vetted as requirement", "author": "codex"}, + ) + assert vet.status_code == 200 + assert vet.json()["stage"] == "requirement" + + bad_promote = await client.post( + f"/suggestions/{suggestion['id']}/promote", + json={"note": "too early"}, + ) + assert bad_promote.status_code == 200 + + promoted = bad_promote.json() + assert promoted["stage"] == "promoted" + assert promoted["promoted_task_id"] is not None + + task = await client.get(f"/tasks/{promoted['promoted_task_id']}") + assert task.status_code == 200 + assert task.json()["title"] == "Promotable gated need" + + fresh = await _create_suggestion(client, title="Decline me", origin_ref="decline-me") + declined = await client.post( + f"/suggestions/{fresh['id']}/decline", + json={"note": "Not needed", "author": "codex"}, + ) + assert declined.status_code == 200 + assert declined.json()["stage"] == "declined" + + illegal = await client.post( + f"/suggestions/{fresh['id']}/vet", + json={"note": "too late"}, + ) + assert illegal.status_code == 409 + + +@pytest.mark.asyncio +async def test_next_steps_surfaces_and_bumps_suggestions(client): + await _create_domain(client, "custodian", "Custodian") + await _create_suggestion(client, title="Surfaced need", origin_ref="surfaced-need") + before = await client.get("/suggestions/?origin_ref=surfaced-need") + # no filter by origin_ref on list - get all and find + all_items = await client.get("/suggestions/") + item = next(i for i in all_items.json() if i["origin_ref"] == "surfaced-need") + assert item["relevance"] == 0 + + steps = await client.get("/state/next_steps") + assert steps.status_code == 200 + payload = steps.json() + assert any(s["type"] == "open_suggestion" for s in payload) + + after = await client.get(f"/suggestions/{item['id']}") + assert after.json()["relevance"] >= 1 + + +@pytest.mark.asyncio +async def test_summary_includes_ranked_suggestions(client): + await _create_domain(client, "custodian", "Custodian") + await _create_suggestion(client, title="Summary ranked", origin_ref="summary-ranked") + summary = await client.get("/state/summary") + assert summary.status_code == 200 + data = summary.json() + assert "ranked_suggestions" in data + assert any(s["origin_ref"] == "summary-ranked" for s in data["ranked_suggestions"]) \ No newline at end of file diff --git a/workplans/CUST-WP-0011-state-hub-threephoenix-migration.md b/workplans/archived/260706-CUST-WP-0011-state-hub-threephoenix-migration.md similarity index 92% rename from workplans/CUST-WP-0011-state-hub-threephoenix-migration.md rename to workplans/archived/260706-CUST-WP-0011-state-hub-threephoenix-migration.md index 158a9a5..9a86e5f 100644 --- a/workplans/CUST-WP-0011-state-hub-threephoenix-migration.md +++ b/workplans/archived/260706-CUST-WP-0011-state-hub-threephoenix-migration.md @@ -4,11 +4,12 @@ type: workplan title: "Pragmatic State Hub Migration to railiance01" domain: infotech repo: state-hub -status: active +status: finished owner: custodian topic_slug: custodian created: "2026-03-11" -updated: "2026-06-25" +updated: "2026-07-06" +finished: "2026-07-06" state_hub_workstream_id: "967baafb-d92d-405a-ba0b-0d00d37c4940" supersedes_intent_from: "Migrate Custodian State Hub to ThreePhoenix Cluster" follow_up_workplan: CUST-WP-0038 @@ -379,9 +380,10 @@ First primary-served write: progress event `56aab39b`. WSL2 fallback restart: ```task id: CUST-WP-0011-T08 -status: todo +status: done priority: medium state_hub_task_id: "e06a59a0-5310-4c1c-9ba5-7cfaadda62e2" +completed: "2026-07-06" ``` Run the cluster State Hub as primary while keeping the WSL2 instance available @@ -398,15 +400,32 @@ Monitor: **Done when:** the agreed stabilisation window passes without data loss or unresolved operational defects. +Completed 2026-07-06: three days post-cutover (2026-07-03) with no data loss +or blocking operational defects. Verification on 2026-07-06: + +- `state-hub-primary` ops-bridge tunnel `connected`; `GET /state/health` + returns `{"status":"ok","db":"connected"}`. +- Deployment `state-hub` 1/1 Ready (one restart 2d8h ago, currently stable). +- CNPG `state-hub-db` reports `Cluster in healthy state`. +- Hub totals growing normally (635 workstreams, 3975 tasks); recent writes + from 2026-07-04 confirmed. +- Manual `POST /consistency/sweep/remote-all` completed with `exit_code: 0`. +- WSL2 fallback path documented and retained (`bridge down state-hub-primary && + make api`). + +Follow-ups outside this workplan: scheduled activity-core sweeps paused after +cutover (service-inventory gap); CNPG scheduled backups not yet configured. + --- ### T09 — Document operating model and defer final WSL2 retirement ```task id: CUST-WP-0011-T09 -status: todo +status: done priority: low state_hub_task_id: "d75a2d49-f3b1-4bdd-b9e1-a1c6a9744681" +completed: "2026-07-06" ``` Document the new operating model: @@ -423,6 +442,13 @@ future HA workplan. **Done when:** runbooks and project instructions match the deployed reality. +Completed 2026-07-06: added `docs/cluster-operating-model.md` covering cluster +access (`state-hub-primary`), remote tunnel mesh, manual backup/restore paths, +WSL2 rollback procedure, consistency sync, pragmatic single-node limits, and +`CUST-WP-0038` deferrals. Updated `README.md`, `AGENTS.md`, +`docs/onboarding.md`, and `infra/README.md` to reference the new runbook. +WSL2 retirement explicitly deferred. + ## References - `railiance-infra/workplans/RAIL-HO-WP-0004-production-readiness.md` diff --git a/workplans/STATE-WP-0061-demand-weighted-suggestion-backlog.md b/workplans/archived/260706-STATE-WP-0061-demand-weighted-suggestion-backlog.md similarity index 68% rename from workplans/STATE-WP-0061-demand-weighted-suggestion-backlog.md rename to workplans/archived/260706-STATE-WP-0061-demand-weighted-suggestion-backlog.md index 0f01abf..a91d260 100644 --- a/workplans/STATE-WP-0061-demand-weighted-suggestion-backlog.md +++ b/workplans/archived/260706-STATE-WP-0061-demand-weighted-suggestion-backlog.md @@ -4,11 +4,12 @@ type: workplan title: "Demand-weighted suggestion backlog (relevance-fed WSJF)" domain: infotech repo: state-hub -status: proposed +status: finished owner: codex topic_slug: custodian created: "2026-06-18" -updated: "2026-06-18" +updated: "2026-07-06" +finished: "2026-07-06" state_hub_workstream_id: "34b446d2-bcd3-4fe3-85e9-32b293839770" --- @@ -75,91 +76,87 @@ scheduled, whose urgency grows with repeated demand." Concretely: ```task id: STATE-WP-0061-T01 -status: todo +status: done priority: high state_hub_task_id: "5cb4d6df-47c1-46c7-af88-4e7db02b2b33" +completed: "2026-07-06" ``` -- [ ] `api/models/suggestion.py`: `Suggestion` (id, domain_id, topic_id?, - workstream_id?, title, description, origin, stage, relevance, - relevance_events, last_requested_at, base_value, job_size, - relevance_weight, promoted_task_id) + `SuggestionNote` (append-only trail). -- [ ] `SuggestionStage` enum: `suggestion | requirement | promoted | declined`. -- [ ] Alembic migration; register model in `api/models/__init__.py`. +- [x] `api/models/suggestion.py`: `Suggestion` + `SuggestionNote` + + `SuggestionRelevanceBump` audit trail. +- [x] `SuggestionStage` enum: `suggestion | requirement | promoted | declined`. +- [x] Alembic migration `f0a1b2c3d4e5`; registered in `api/models/__init__.py`. ### T2 — API + MCP sanctioned write layer ```task id: STATE-WP-0061-T02 -status: todo +status: done priority: high state_hub_task_id: "ebc5238c-0714-4413-99ca-37bb2468ac58" +completed: "2026-07-06" ``` -- [ ] REST + MCP: `create_suggestion`, `vet_suggestion` (→ requirement, with - structured fields + note), `decline_suggestion`, `promote_suggestion_to_task` - (creates a `Task`, sets `promoted_task_id`, stage→promoted), and `list/get`. -- [ ] `bump_relevance(id, reason)` — sanctioned write; appends a relevance event, - increments counter, sets `last_requested_at`. -- [ ] Document these as sanctioned writes (alongside `resolve_decision`). +- [x] REST `api/routers/suggestions.py` + MCP tools for create/vet/decline/promote/list/get. +- [x] `POST /suggestions/{id}/bump-relevance` with debounced relevance bumps. +- [x] Documented in `mcp_server/TOOLS.md`, `docs/capabilities.md`, `INTENT.md`. ### T3 — Relevance emission wiring ("needed but not done") ```task id: STATE-WP-0061-T03 -status: todo +status: done priority: high state_hub_task_id: "e7e87595-8af8-43f3-8372-0ddde44a5b82" +completed: "2026-07-06" ``` -- [ ] Define the demand events that bump relevance: (a) `get_next_steps` / - dependency lookup resolves to an open suggestion/requirement; (b) a - `CapabilityRequest` matches an unfulfilled suggestion; (c) an explicit agent - bump when it hits a gap (the WP-0012 routing-scenario case). -- [ ] Wire (a) and (b) in-hub; expose (c) via the MCP write from T2. -- [ ] Idempotency/debounce so a single lookup does not double-count. +- [x] `get_next_steps` surfaces open suggestions and bumps surfaced items. +- [x] Capability request create bumps matching open suggestions. +- [x] Explicit `bump_suggestion_relevance` MCP/REST write. +- [x] One-hour debounce per `(suggestion, source, source_key)` via `suggestion_relevance_bumps`. ### T4 — WSJF projection + ranked endpoint ```task id: STATE-WP-0061-T04 -status: todo +status: done priority: high state_hub_task_id: "f6fccd58-5c47-4509-ba0b-9f606dfb53de" +completed: "2026-07-06" ``` -- [ ] Pure projection: `wsjf = (base_value + relevance_weight × relevance) / job_size`. -- [ ] `GET /suggestions?rank=wsjf` returns suggestions/requirements ordered by score - (promoted/declined excluded by default). -- [ ] Feed the activity-core daily triage: include the ranked suggestion list in - the `daily_triage` report input (coordinate with CUST-WP-0044 runner). +- [x] WSJF projection in `api/services/suggestion_wsjf.py`. +- [x] `GET /suggestions?rank=wsjf` with terminal exclusion by default. +- [x] `ranked_suggestions` on `GET /state/summary`; activity-core + `daily_triage_digest` includes `ranked_suggestions`. ### T5 — Dashboard surface ```task id: STATE-WP-0061-T05 -status: todo +status: done priority: medium state_hub_task_id: "4dcca789-3c63-46fb-a1ec-9ae9a68d1a4b" +completed: "2026-07-06" ``` -- [ ] `/suggestions` page: ranked table (stage, relevance, WSJF, last requested), - with vet/promote/decline actions guarded to the sanctioned write layer. -- [ ] Link from `/wsjf-triage`; short `src/docs/suggestions.md`. +- [x] `/suggestions` dashboard page with WSJF-ranked live table. +- [x] Linked from `/wsjf-triage`; `dashboard/src/docs/suggestions.md` added. ### T6 — Tests, docs, ADR amendment ```task id: STATE-WP-0061-T06 -status: todo +status: done priority: medium state_hub_task_id: "a7832268-fa2b-4531-b91f-dc31f92830af" +completed: "2026-07-06" ``` -- [ ] Tests: model + migration, relevance bump idempotency, WSJF ordering, - promotion creates a linked task, stage transitions reject illegal moves. -- [ ] SCOPE/INTENT note; amend the read-model ADR to list the new sanctioned writes. -- [ ] Backfill example: register the gated WP-0012 routing scenarios as suggestions. +- [x] `tests/test_suggestions.py` — WSJF order, debounce, promotion, illegal transitions. +- [x] `SCOPE.md`, `INTENT.md`, `docs/activity-core-delegation.md` updated. +- [x] `scripts/seed_wp0012_suggestions.py` for WARDEN-WP-0012 scenario backfill. ---