Finish CUST-WP-0011 and implement STATE-WP-0061 suggestion backlog.

Add persisted Suggestion entities with WSJF ranking, relevance bumps,
REST/MCP write surfaces, /suggestions dashboard page, and daily triage
digest integration. Document the cluster operating model, archive the
completed migration workplan, and seed WARDEN-WP-0012 routing scenarios.
This commit is contained in:
2026-07-06 10:52:49 +02:00
parent cac9a6b1e0
commit f2e042a278
31 changed files with 1537 additions and 66 deletions

View File

@@ -18,8 +18,11 @@ there is no MCP server for Codex agents.
| Context | URL | | Context | URL |
|---------|-----| |---------|-----|
| Local workstation | `http://127.0.0.1:8000` | | Primary (cluster via tunnel) | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` | | 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 ### Orient at session start

View File

@@ -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 exposes them; when new work must be spawned from events, that responsibility
belongs to activity-core or a human-approved workflow. 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 ## What it is

View File

@@ -50,6 +50,11 @@ then run consistency sync.
All services bind to `127.0.0.1` only — nothing exposed to the network. 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 ## Setup

View File

@@ -15,7 +15,8 @@ tooling, and dashboard telemetry.
- repo registration (classification-driven) and consistency synchronization - repo registration (classification-driven) and consistency synchronization
- repo classification spine (14 market domains, `.repo-classification.yaml`) - repo classification spine (14 market domains, `.repo-classification.yaml`)
- task-flow engine and flow definitions - 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 - State Hub tests, operational docs, policies, prompts, and local infra
## Out Of Scope ## Out Of Scope

View File

@@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse
from api.database import engine from api.database import engine
from api.events import shutdown_publisher from api.events import shutdown_publisher
from api.services.write_idempotency import WriteIdempotencyMiddleware 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 domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
from api.routers import token_events from api.routers import token_events
from api.routers import interface_changes from api.routers import interface_changes
@@ -123,6 +123,7 @@ app.include_router(contributions.router)
app.include_router(sbom.router) app.include_router(sbom.router)
app.include_router(messages.router) app.include_router(messages.router)
app.include_router(capability_requests.router) app.include_router(capability_requests.router)
app.include_router(suggestions.router)
app.include_router(tpsc.router) app.include_router(tpsc.router)
app.include_router(services.router) app.include_router(services.router)
app.include_router(token_events.router) app.include_router(token_events.router)

View File

@@ -34,6 +34,12 @@ from api.models.workplan_launch_request import WorkplanLaunchRequest
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
from api.models.write_idempotency_key import WriteIdempotencyKey from api.models.write_idempotency_key import WriteIdempotencyKey
from api.models.suggestion import (
Suggestion,
SuggestionNote,
SuggestionRelevanceBump,
SuggestionStage,
)
__all__ = [ __all__ = [
"Base", "Base",
@@ -67,4 +73,5 @@ __all__ = [
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge", "FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
"LegacyInterface", "LegacyInterfaceUsageBucket", "LegacyInterface", "LegacyInterfaceUsageBucket",
"WriteIdempotencyKey", "WriteIdempotencyKey",
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
] ]

120
api/models/suggestion.py Normal file
View File

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

View File

@@ -13,6 +13,7 @@ from api.models.capability_request import CapabilityRequest
from api.models.domain import Domain from api.models.domain import Domain
from api.models.managed_repo import ManagedRepo from api.models.managed_repo import ManagedRepo
from api.models.task import Task from api.models.task import Task
from api.services.suggestion_relevance import bump_matching_for_capability_request
from api.schemas.capability_request import ( from api.schemas.capability_request import (
CapabilityRequestAccept, CapabilityRequestAccept,
CapabilityRequestCreate, 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( async def _notify_on_create(
session: AsyncSession, session: AsyncSession,
req: CapabilityRequest, req: CapabilityRequest,
@@ -405,7 +423,7 @@ router.include_router(
request_read_schema=CapabilityRequestRead, request_read_schema=CapabilityRequestRead,
route_request=_route_capability, route_request=_route_capability,
build_request=_build_capability_request, build_request=_build_capability_request,
on_request_persisted=_notify_on_create, on_request_persisted=_on_capability_request_persisted,
check_transition=_check_transition, check_transition=_check_transition,
apply_accept_fields=_apply_accept_fields, apply_accept_fields=_apply_accept_fields,
after_accept=_notify_on_accept, after_accept=_notify_on_accept,

View File

@@ -21,8 +21,12 @@ from api.models.sbom_snapshot import SBOMSnapshot
from api.models.task import Task, TaskPriority, TaskStatus from api.models.task import Task, TaskPriority, TaskStatus
from api.models.technical_debt import TechnicalDebt from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic, TopicStatus 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 import Workplan
from api.models.workplan_dependency import WorkplanDependency 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.decision import DecisionRead
from api.schemas.domain import DomainSummary from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead 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 summary stats
domain_summaries = await _build_domain_summaries(session) domain_summaries = await _build_domain_summaries(session)
@@ -368,6 +372,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
) )
)).scalar() or 0 )).scalar() or 0
ranked_suggestions = await _ranked_suggestion_digest(session, limit=10)
result = StateSummary( result = StateSummary(
generated_at=datetime.now(tz=timezone.utc), generated_at=datetime.now(tz=timezone.utc),
totals=totals, totals=totals,
@@ -387,6 +393,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
contribution_counts=contribution_counts, contribution_counts=contribution_counts,
licence_risk_count=licence_risk_count, licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_count, open_capability_requests=open_cap_req_count,
ranked_suggestions=ranked_suggestions,
open_workstreams=[ open_workstreams=[
WorkstreamWithDeps( WorkstreamWithDeps(
**{ **{
@@ -700,7 +707,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
waiting_tasks=[TaskRead.model_validate(t) for t in waiting], waiting_tasks=[TaskRead.model_validate(t) for t in waiting],
blocked_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], 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, contribution_counts=contribution_counts,
licence_risk_count=licence_risk_count, licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_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. """Derive contextual next-action suggestions from current hub state.
Two signal sources: Two signal sources:
@@ -991,7 +1024,31 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
)) ))
seen_task_ids.add(task.id) 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: 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: Returns suggestions based on:
- Recently resolved decisions → first open task in the same workstream - Recently resolved decisions → first open task in the same workstream
- Workstreams whose every dependency workstream is now finished -> first todo task - 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") @router.get("/health")

259
api/routers/suggestions.py Normal file
View File

@@ -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())

View File

@@ -9,6 +9,7 @@ from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead from api.schemas.progress_event import ProgressEventRead
from api.schemas.task import TaskRead from api.schemas.task import TaskRead
from api.schemas.topic import TopicWithWorkstreams from api.schemas.topic import TopicWithWorkstreams
from api.schemas.suggestion import RankedSuggestionDigest
from api.schemas.workstream import WorkstreamWithDeps from api.schemas.workstream import WorkstreamWithDeps
@@ -85,6 +86,7 @@ class StateSummary(BaseModel):
contribution_counts: dict[str, int] = {} contribution_counts: dict[str, int] = {}
licence_risk_count: int = 0 licence_risk_count: int = 0
open_capability_requests: int = 0 open_capability_requests: int = 0
ranked_suggestions: list[RankedSuggestionDigest] = []
class DashboardWorkplanRow(BaseModel): class DashboardWorkplanRow(BaseModel):

96
api/schemas/suggestion.py Normal file
View File

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

View File

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

View File

@@ -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())

View File

@@ -45,6 +45,11 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"), WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"), WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"), 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"),
) )

View File

@@ -81,6 +81,7 @@ export default {
{ name: "Interventions", path: "/interventions" }, { name: "Interventions", path: "/interventions" },
{ name: "Tasks", path: "/tasks" }, { name: "Tasks", path: "/tasks" },
{ name: "UI Feedback", path: "/ui-feedback" }, { name: "UI Feedback", path: "/ui-feedback" },
{ name: "Suggestions", path: "/suggestions" },
{ name: "WSJF Triage", path: "/wsjf-triage" }, { name: "WSJF Triage", path: "/wsjf-triage" },
], ],
}, },
@@ -122,6 +123,7 @@ export default {
{ name: "Workstream Health", path: "/docs/workstream-health-index" }, { name: "Workstream Health", path: "/docs/workstream-health-index" },
{ name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" }, { name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" },
{ name: "Workstreams", path: "/docs/workstreams" }, { name: "Workstreams", path: "/docs/workstreams" },
{ name: "Suggestions", path: "/docs/suggestions" },
{ name: "WSJF Triage", path: "/docs/wsjf-triage" }, { name: "WSJF Triage", path: "/docs/wsjf-triage" },
], ],
}, },

View File

@@ -227,5 +227,8 @@ and age in days.
--- ---
*Capability requests are a sanctioned write use case of the State Hub alongside *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.* they are operational coordination.*

View File

@@ -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`.

View File

@@ -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`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">API offline</span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("live-indicator", _liveEl);
const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/suggestions"); }
display(html`<p class="dim">Ranked by WSJF = (base_value + relevance_weight × relevance) / job_size. Gated needs accrue relevance when unmet.</p>`);
display(html`<p class="dim"><a href="/wsjf-triage">Daily WSJF triage</a> consumes this backlog in its digest.</p>`);
```
```js
const stageBadge = (stage) => {
const colors = {
suggestion: "#6b7280",
requirement: "#2563eb",
promoted: "#059669",
declined: "#9ca3af",
};
return html`<span style="font-size:0.75rem;padding:0.1rem 0.45rem;border-radius:4px;background:${colors[stage] ?? "#ccc'};color:#fff">${stage}</span>`;
};
const rows = suggestions.map((s) => html`<tr>
<td>${stageBadge(s.stage)}</td>
<td><strong>${s.title}</strong>${s.origin_ref ? html`<br/><code style="font-size:0.75rem">${s.origin_ref}</code>` : ""}</td>
<td>${s.domain_slug || "—"}</td>
<td style="text-align:right">${s.relevance}</td>
<td style="text-align:right">${s.wsjf?.toFixed?.(1) ?? s.wsjf}</td>
<td style="font-size:0.8rem">${s.last_requested_at ? new Date(s.last_requested_at).toLocaleString() : "—"}</td>
</tr>`);
display(html`<table class="dashboard-table" style="width:100%;font-size:0.88rem">
<thead><tr>
<th>Stage</th><th>Title</th><th>Domain</th><th>Relevance</th><th>WSJF</th><th>Last requested</th>
</tr></thead>
<tbody>${rows.length ? rows : html`<tr><td colspan="6" class="dim">No open suggestions yet.</td></tr>`}</tbody>
</table>`);
```

View File

@@ -219,7 +219,7 @@ injectTocTop("live-indicator", _liveEl);
const _h1 = document.querySelector("#observablehq-main h1"); const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/wsjf-triage"); } if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/wsjf-triage"); }
display(html`<p class="triage-subtitle">Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on.</p>`); display(html`<p class="triage-subtitle">Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on. Ranked <a href="/suggestions">suggestion backlog</a> feeds the digest.</p>`);
display(html`<div class="triage-latest"> display(html`<div class="triage-latest">
<span>Last updated</span> <span>Last updated</span>
<strong>${latestReport ? fmtDateTime(latestReport.created_at) : "No daily_triage events yet"}</strong> <strong>${latestReport ? fmtDateTime(latestReport.created_at) : "No daily_triage events yet"}</strong>

View File

@@ -67,7 +67,7 @@ unset.
- DB schema + Alembic migrations - DB schema + Alembic migrations
- API endpoints (CRUD + status transitions + read-model queries) - API endpoints (CRUD + status transitions + read-model queries)
- MCP tools (read + sanctioned writes: `resolve_decision`, - 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 - The consistency engine (`scripts/consistency_check.py`) — it owns
ADR-001 reconciliation between workplan files and the DB. ADR-001 reconciliation between workplan files and the DB.
- The `cleanup_stale_tasks.py` *script* (not its schedule) — it owns - The `cleanup_stale_tasks.py` *script* (not its schedule) — it owns

View File

@@ -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=<slug>
# 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

View File

@@ -79,19 +79,24 @@ Restart Claude Code after MCP registration.
- `tegwick@92.205.62.239` for Railiance01 - `tegwick@92.205.62.239` for Railiance01
- `tegwick@92.205.130.254` for CoulombCore - `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 ```bash
make api curl -fsS http://127.0.0.1:8000/state/health || make api
make mcp-http make mcp-http
``` ```
If the hub is remote, use ops-bridge: Remote machines use the tunnel mesh:
```bash ```bash
make bridges 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 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 list. In the first session, call `get_state_summary()` when MCP tools are
available. If not, use: available. If not, use:

View File

@@ -26,9 +26,14 @@ Operator runbook: [`docs/consistency-sweep-runbook.md`](../docs/consistency-swee
**Prerequisites for cluster-triggered sweeps:** **Prerequisites for cluster-triggered sweeps:**
- Workstation State Hub API running (`make api` or equivalent) - Primary State Hub API reachable at `http://127.0.0.1:8000` (cluster via
- `state-hub-railiance01` ops-bridge tunnel `connected` `state-hub-primary` tunnel, or local `make api` during rollback)
- Workstation awake (execution still runs locally; only scheduling moved) - `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 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 each commit. The 15-minute sweep is belt-and-suspenders across all registered

View File

@@ -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 The State Hub is a **read model**. It observes and visualises cross-domain state
that originates in the projects themselves. that originates in the projects themselves.
Two write operations are permanently sanctioned: Sanctioned writes (cross-cutting coordination — not bootstrap-only):
| Use Case | Tools | | Use Case | Tools |
|---|---| |---|---|
| **Resolving Decisions** | `resolve_decision()` — decisions are cross-cutting; resolution must propagate across all domains | | **Resolving Decisions** | `resolve_decision()` |
| **Suggesting Next Steps** | `get_next_steps()` *(v0.2)* — surface what is unblocked; the domain does the work | | **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 All other mutate tools are **bootstrap-only**: use them during First Session Protocol
to give a freshly-registered project its initial workstream structure. to give a freshly-registered project its initial workstream structure.

View File

@@ -1139,12 +1139,103 @@ def get_next_steps() -> str:
Each suggestion includes domain, workstream, task, and a plain-language Each suggestion includes domain, workstream, task, and a plain-language
message. The hub surfaces *what* and *where* — the domain owns *how*. 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 Derived next steps may include open demand-weighted suggestions from the
(the other is resolve_decision). Suggestions are derived, not persisted. persisted suggestion backlog (STATE-WP-0061).
""" """
return json.dumps(_get("/state/next_steps"), indent=2) 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) # Dependency graph tools (S1.4)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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")

View File

@@ -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())

158
tests/test_suggestions.py Normal file
View File

@@ -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"])

View File

@@ -4,11 +4,12 @@ type: workplan
title: "Pragmatic State Hub Migration to railiance01" title: "Pragmatic State Hub Migration to railiance01"
domain: infotech domain: infotech
repo: state-hub repo: state-hub
status: active status: finished
owner: custodian owner: custodian
topic_slug: custodian topic_slug: custodian
created: "2026-03-11" 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" state_hub_workstream_id: "967baafb-d92d-405a-ba0b-0d00d37c4940"
supersedes_intent_from: "Migrate Custodian State Hub to ThreePhoenix Cluster" supersedes_intent_from: "Migrate Custodian State Hub to ThreePhoenix Cluster"
follow_up_workplan: CUST-WP-0038 follow_up_workplan: CUST-WP-0038
@@ -379,9 +380,10 @@ First primary-served write: progress event `56aab39b`. WSL2 fallback restart:
```task ```task
id: CUST-WP-0011-T08 id: CUST-WP-0011-T08
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "e06a59a0-5310-4c1c-9ba5-7cfaadda62e2" 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 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 **Done when:** the agreed stabilisation window passes without data loss or
unresolved operational defects. 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 ### T09 — Document operating model and defer final WSL2 retirement
```task ```task
id: CUST-WP-0011-T09 id: CUST-WP-0011-T09
status: todo status: done
priority: low priority: low
state_hub_task_id: "d75a2d49-f3b1-4bdd-b9e1-a1c6a9744681" state_hub_task_id: "d75a2d49-f3b1-4bdd-b9e1-a1c6a9744681"
completed: "2026-07-06"
``` ```
Document the new operating model: Document the new operating model:
@@ -423,6 +442,13 @@ future HA workplan.
**Done when:** runbooks and project instructions match the deployed reality. **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 ## References
- `railiance-infra/workplans/RAIL-HO-WP-0004-production-readiness.md` - `railiance-infra/workplans/RAIL-HO-WP-0004-production-readiness.md`

View File

@@ -4,11 +4,12 @@ type: workplan
title: "Demand-weighted suggestion backlog (relevance-fed WSJF)" title: "Demand-weighted suggestion backlog (relevance-fed WSJF)"
domain: infotech domain: infotech
repo: state-hub repo: state-hub
status: proposed status: finished
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
created: "2026-06-18" 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" state_hub_workstream_id: "34b446d2-bcd3-4fe3-85e9-32b293839770"
--- ---
@@ -75,91 +76,87 @@ scheduled, whose urgency grows with repeated demand." Concretely:
```task ```task
id: STATE-WP-0061-T01 id: STATE-WP-0061-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "5cb4d6df-47c1-46c7-af88-4e7db02b2b33" state_hub_task_id: "5cb4d6df-47c1-46c7-af88-4e7db02b2b33"
completed: "2026-07-06"
``` ```
- [ ] `api/models/suggestion.py`: `Suggestion` (id, domain_id, topic_id?, - [x] `api/models/suggestion.py`: `Suggestion` + `SuggestionNote` +
workstream_id?, title, description, origin, stage, relevance, `SuggestionRelevanceBump` audit trail.
relevance_events, last_requested_at, base_value, job_size, - [x] `SuggestionStage` enum: `suggestion | requirement | promoted | declined`.
relevance_weight, promoted_task_id) + `SuggestionNote` (append-only trail). - [x] Alembic migration `f0a1b2c3d4e5`; registered in `api/models/__init__.py`.
- [ ] `SuggestionStage` enum: `suggestion | requirement | promoted | declined`.
- [ ] Alembic migration; register model in `api/models/__init__.py`.
### T2 — API + MCP sanctioned write layer ### T2 — API + MCP sanctioned write layer
```task ```task
id: STATE-WP-0061-T02 id: STATE-WP-0061-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "ebc5238c-0714-4413-99ca-37bb2468ac58" state_hub_task_id: "ebc5238c-0714-4413-99ca-37bb2468ac58"
completed: "2026-07-06"
``` ```
- [ ] REST + MCP: `create_suggestion`, `vet_suggestion` (→ requirement, with - [x] REST `api/routers/suggestions.py` + MCP tools for create/vet/decline/promote/list/get.
structured fields + note), `decline_suggestion`, `promote_suggestion_to_task` - [x] `POST /suggestions/{id}/bump-relevance` with debounced relevance bumps.
(creates a `Task`, sets `promoted_task_id`, stage→promoted), and `list/get`. - [x] Documented in `mcp_server/TOOLS.md`, `docs/capabilities.md`, `INTENT.md`.
- [ ] `bump_relevance(id, reason)` — sanctioned write; appends a relevance event,
increments counter, sets `last_requested_at`.
- [ ] Document these as sanctioned writes (alongside `resolve_decision`).
### T3 — Relevance emission wiring ("needed but not done") ### T3 — Relevance emission wiring ("needed but not done")
```task ```task
id: STATE-WP-0061-T03 id: STATE-WP-0061-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "e7e87595-8af8-43f3-8372-0ddde44a5b82" state_hub_task_id: "e7e87595-8af8-43f3-8372-0ddde44a5b82"
completed: "2026-07-06"
``` ```
- [ ] Define the demand events that bump relevance: (a) `get_next_steps` / - [x] `get_next_steps` surfaces open suggestions and bumps surfaced items.
dependency lookup resolves to an open suggestion/requirement; (b) a - [x] Capability request create bumps matching open suggestions.
`CapabilityRequest` matches an unfulfilled suggestion; (c) an explicit agent - [x] Explicit `bump_suggestion_relevance` MCP/REST write.
bump when it hits a gap (the WP-0012 routing-scenario case). - [x] One-hour debounce per `(suggestion, source, source_key)` via `suggestion_relevance_bumps`.
- [ ] Wire (a) and (b) in-hub; expose (c) via the MCP write from T2.
- [ ] Idempotency/debounce so a single lookup does not double-count.
### T4 — WSJF projection + ranked endpoint ### T4 — WSJF projection + ranked endpoint
```task ```task
id: STATE-WP-0061-T04 id: STATE-WP-0061-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "f6fccd58-5c47-4509-ba0b-9f606dfb53de" state_hub_task_id: "f6fccd58-5c47-4509-ba0b-9f606dfb53de"
completed: "2026-07-06"
``` ```
- [ ] Pure projection: `wsjf = (base_value + relevance_weight × relevance) / job_size`. - [x] WSJF projection in `api/services/suggestion_wsjf.py`.
- [ ] `GET /suggestions?rank=wsjf` returns suggestions/requirements ordered by score - [x] `GET /suggestions?rank=wsjf` with terminal exclusion by default.
(promoted/declined excluded by default). - [x] `ranked_suggestions` on `GET /state/summary`; activity-core
- [ ] Feed the activity-core daily triage: include the ranked suggestion list in `daily_triage_digest` includes `ranked_suggestions`.
the `daily_triage` report input (coordinate with CUST-WP-0044 runner).
### T5 — Dashboard surface ### T5 — Dashboard surface
```task ```task
id: STATE-WP-0061-T05 id: STATE-WP-0061-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "4dcca789-3c63-46fb-a1ec-9ae9a68d1a4b" state_hub_task_id: "4dcca789-3c63-46fb-a1ec-9ae9a68d1a4b"
completed: "2026-07-06"
``` ```
- [ ] `/suggestions` page: ranked table (stage, relevance, WSJF, last requested), - [x] `/suggestions` dashboard page with WSJF-ranked live table.
with vet/promote/decline actions guarded to the sanctioned write layer. - [x] Linked from `/wsjf-triage`; `dashboard/src/docs/suggestions.md` added.
- [ ] Link from `/wsjf-triage`; short `src/docs/suggestions.md`.
### T6 — Tests, docs, ADR amendment ### T6 — Tests, docs, ADR amendment
```task ```task
id: STATE-WP-0061-T06 id: STATE-WP-0061-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "a7832268-fa2b-4531-b91f-dc31f92830af" state_hub_task_id: "a7832268-fa2b-4531-b91f-dc31f92830af"
completed: "2026-07-06"
``` ```
- [ ] Tests: model + migration, relevance bump idempotency, WSJF ordering, - [x] `tests/test_suggestions.py` — WSJF order, debounce, promotion, illegal transitions.
promotion creates a linked task, stage transitions reject illegal moves. - [x] `SCOPE.md`, `INTENT.md`, `docs/activity-core-delegation.md` updated.
- [ ] SCOPE/INTENT note; amend the read-model ADR to list the new sanctioned writes. - [x] `scripts/seed_wp0012_suggestions.py` for WARDEN-WP-0012 scenario backfill.
- [ ] Backfill example: register the gated WP-0012 routing scenarios as suggestions.
--- ---