diff --git a/AGENTS.md b/AGENTS.md
index 4fcf602..2bbd630 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,9 +43,12 @@ with `make -C ~/state-hub configure-codex WITH_MCP=1`.
# Offline brief — works without hub connection
cat .custodian-brief.md
-# Active workplans for this domain
+# Active workplans for this domain (default summary omits flavor: residual)
curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool
+# Residuals are not implementation demand unless promoted:
+# GET /state/summary (open_workplans omits flavor=residual)
+# GET /workplans/?flavor=residual
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=state-hub&unread_only=true" \
diff --git a/README.md b/README.md
index 8b3055b..7193dbd 100644
--- a/README.md
+++ b/README.md
@@ -264,7 +264,8 @@ use `/state/health`, not `/state/summary`.
| `/state/summary` | Full snapshot |
| `/state/health` | DB connectivity check |
-See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
+See `docs/work-record-flavor.md` for flavor buckets and residual
+default-exclusion (STATE-WP-0092). See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
compatibility policy and retirement criteria.
---
diff --git a/api/models/task.py b/api/models/task.py
index c37816a..b91d14e 100644
--- a/api/models/task.py
+++ b/api/models/task.py
@@ -42,6 +42,7 @@ class Task(Base, TimestampMixin):
# title, so renaming a heading looks like one task vanishing and another
# appearing.
record_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
+ flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[TaskStatus] = mapped_column(
diff --git a/api/models/workplan.py b/api/models/workplan.py
index f3f9f2a..8006b28 100644
--- a/api/models/workplan.py
+++ b/api/models/workplan.py
@@ -39,6 +39,9 @@ class Workplan(Base, TimestampMixin):
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
+ flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
+ flavor_promotion_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ flavor_promoted_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
diff --git a/api/routers/state.py b/api/routers/state.py
index 4990d62..c78f238 100644
--- a/api/routers/state.py
+++ b/api/routers/state.py
@@ -1,7 +1,7 @@
import time
from datetime import datetime, timedelta, timezone
-from fastapi import APIRouter, Depends, Request, Response
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -75,6 +75,12 @@ def _dual_workplan_refs(
"workstream_slug": slug,
}
from api.task_status import TERMINAL_TASK_STATUSES, status_value
+from api.work_record_flavor import (
+ RESIDUAL_FLAVOR,
+ WORK_RECORD_FLAVORS,
+ is_residual_flavor,
+ normalize_flavor,
+)
from api.workplan_status import (
CLOSED_WORKPLAN_STATUSES,
OPEN_WORKPLAN_STATUSES,
@@ -100,12 +106,44 @@ def _summary_cache_headers(
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120"
+def _apply_summary_flavor_view(
+ summary: StateSummary,
+ *,
+ include_residuals: bool,
+ flavor: str | None,
+) -> StateSummary:
+ """Default views omit residual workplans; totals.residual_open stays honest."""
+ wanted = normalize_flavor(flavor)
+ if wanted is not None and wanted not in WORK_RECORD_FLAVORS:
+ raise HTTPException(
+ status_code=422,
+ detail=(
+ f"Unknown work-record flavor {wanted!r}; "
+ f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
+ ),
+ )
+ show_residuals = include_residuals or wanted == RESIDUAL_FLAVOR
+ residual_rows = list(summary.residual_open_workplans)
+ open_rows = list(summary.open_workplans)
+ if wanted is not None:
+ open_rows = [row for row in open_rows if row.flavor == wanted]
+ residual_rows = [row for row in residual_rows if row.flavor == wanted]
+ return summary.model_copy(
+ update={
+ "open_workplans": open_rows,
+ "residual_open_workplans": residual_rows if show_residuals else [],
+ }
+ )
+
+
@router.get("/summary", response_model=StateSummary)
async def get_summary(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
refresh: bool = False,
+ include_residuals: bool = Query(False),
+ flavor: str | None = Query(None),
) -> StateSummary:
revision = await fetch_summary_revision(session)
revision_token = revision.combined_fingerprint()
@@ -116,22 +154,38 @@ async def get_summary(
if cache_status == "hit-revision" and cached is not None:
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
- return cached.model_copy(update={"ops_runs": await get_ops_run_projection()})
+ return _apply_summary_flavor_view(
+ cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
+ include_residuals=include_residuals,
+ flavor=flavor,
+ )
if cache_status == "progress-section" and cached is not None:
result = await apply_progress_section(session, cached, revision)
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
- return result.model_copy(update={"ops_runs": await get_ops_run_projection()})
+ return _apply_summary_flavor_view(
+ result.model_copy(update={"ops_runs": await get_ops_run_projection()}),
+ include_residuals=include_residuals,
+ flavor=flavor,
+ )
if cache_status == "stale" and cached is not None:
cache.schedule_refresh(revision)
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
- return cached.model_copy(update={"ops_runs": await get_ops_run_projection()})
+ return _apply_summary_flavor_view(
+ cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
+ include_residuals=include_residuals,
+ flavor=flavor,
+ )
result = await build_state_summary(session)
cache.store(result, revision)
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
- return result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)})
+ return _apply_summary_flavor_view(
+ result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)}),
+ include_residuals=include_residuals,
+ flavor=flavor,
+ )
async def build_state_summary(session: AsyncSession) -> StateSummary:
@@ -336,6 +390,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
),
archived=ws_counts.get("archived", 0),
total=sum(ws_counts.values()),
+ residual_open=0,
),
tasks=TaskTotals(
wait=task_counts.get(TaskStatus.wait, 0),
@@ -397,6 +452,15 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
)
for w in open_ws
]
+ relevant_rows = [
+ row for row, w in zip(open_workplan_rows, open_ws)
+ if not is_residual_flavor(w.flavor)
+ ]
+ residual_rows = [
+ row for row, w in zip(open_workplan_rows, open_ws)
+ if is_residual_flavor(w.flavor)
+ ]
+ totals.workstreams.residual_open = len(residual_rows)
result = StateSummary(
generated_at=datetime.now(tz=timezone.utc),
@@ -418,7 +482,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_count,
ranked_suggestions=ranked_suggestions,
- open_workplans=open_workplan_rows,
+ open_workplans=relevant_rows,
+ residual_open_workplans=residual_rows,
)
return result
@@ -521,6 +586,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
w for w in workstreams_all
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
]
+ residual_open = sum(1 for w in open_ws if is_residual_flavor(w.flavor))
open_ws_ids = [w.id for w in open_ws]
dep_rows = []
if open_ws_ids:
@@ -580,6 +646,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
),
archived=ws_counts.get("archived", 0),
total=sum(ws_counts.values()),
+ residual_open=residual_open,
),
tasks=TaskTotals(
wait=task_totals_by_status.get("wait", 0),
@@ -668,6 +735,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
id=w.id,
title=w.title,
status=normalize_workplan_status(w.status),
+ flavor=w.flavor,
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
workplan_filename=workplan.get("filename"),
@@ -795,7 +863,10 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
@router.get("/deps", response_model=list[WorkstreamWithDeps])
-async def get_deps(session: AsyncSession = Depends(get_session)) -> list[WorkstreamWithDeps]:
+async def get_deps(
+ session: AsyncSession = Depends(get_session),
+ include_residuals: bool = Query(False),
+) -> list[WorkstreamWithDeps]:
"""Lightweight dep-graph endpoint: open workstreams with their dependency edges only.
Returns the same structure as open_workplans in /state/summary but skips
@@ -809,6 +880,8 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
+ if not include_residuals:
+ open_ws = [w for w in open_ws if not is_residual_flavor(w.flavor)]
open_ws_ids = [w.id for w in open_ws]
dep_rows = []
@@ -944,6 +1017,10 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
if task.id in seen_task_ids:
continue
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
+ if ws is not None and is_residual_flavor(ws.flavor):
+ continue
+ if is_residual_flavor(task.flavor):
+ continue
domain_slug = await _get_domain_slug_for_workplan(ws, session)
steps.append(NextStep(
type="resolved_decision",
@@ -988,6 +1065,7 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
Workplan.title,
Workplan.slug,
Workplan.topic_id,
+ Workplan.flavor,
).where(Workplan.id.in_(dep_ws_ids))
)
ws_info = {
@@ -996,8 +1074,9 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
"title": title,
"slug": slug,
"topic_id": topic_id,
+ "flavor": flavor,
}
- for ws_id, status, title, slug, topic_id in ws_rows
+ for ws_id, status, title, slug, topic_id, flavor in ws_rows
}
ready_from_ws_ids = [
@@ -1023,12 +1102,16 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
for from_ws_id in ready_from_ws_ids:
from_ws = ws_info.get(from_ws_id, {})
+ if is_residual_flavor(from_ws.get("flavor")):
+ continue
todo_tasks = todo_by_ws.get(from_ws_id, [])
if not todo_tasks:
continue
task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
if task.id in seen_task_ids:
continue
+ if is_residual_flavor(task.flavor):
+ continue
domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session)
_blocker_slugs = []
for tid in dep_map[from_ws_id]:
diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py
index bca8e47..bd636cd 100644
--- a/api/routers/workstreams.py
+++ b/api/routers/workstreams.py
@@ -9,7 +9,7 @@ from typing import Any
import yaml
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
-from sqlalchemy import select
+from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
@@ -24,6 +24,11 @@ from api.schemas.workplan import (
)
from api.services.lifecycle import transition_workplan_status
from api.services.legacy_compat import retire_legacy_route
+from api.work_record_flavor import (
+ RESIDUAL_FLAVOR,
+ WORK_RECORD_FLAVORS,
+ normalize_flavor,
+)
from api.workplan_status import (
is_supported_workplan_status,
normalize_workplan_status,
@@ -88,6 +93,8 @@ async def _list_workplans(
status_filter: str | None,
owner: str | None,
slug: str | None,
+ flavor: str | None = None,
+ include_residuals: bool = True,
session: AsyncSession,
) -> list[Workplan]:
q = select(Workplan)
@@ -106,6 +113,19 @@ async def _list_workplans(
q = q.where(Workplan.owner == owner)
if slug:
q = q.where(Workplan.slug == slug)
+ wanted = normalize_flavor(flavor)
+ if wanted is not None:
+ if wanted not in WORK_RECORD_FLAVORS:
+ raise HTTPException(
+ status_code=422,
+ detail=(
+ f"Unknown work-record flavor {wanted!r}; "
+ f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
+ ),
+ )
+ q = q.where(Workplan.flavor == wanted)
+ elif not include_residuals:
+ q = q.where(or_(Workplan.flavor.is_(None), Workplan.flavor != RESIDUAL_FLAVOR))
q = q.order_by(
Workplan.planning_priority.asc().nullslast(),
Workplan.planning_order.asc().nullslast(),
@@ -365,6 +385,8 @@ async def list_workplans(
status: str | None = None,
owner: str | None = None,
slug: str | None = None,
+ flavor: str | None = Query(None),
+ include_residuals: bool = Query(True),
session: AsyncSession = Depends(get_session),
) -> list[Workplan]:
return await _list_workplans(
@@ -374,6 +396,8 @@ async def list_workplans(
status_filter=status,
owner=owner,
slug=slug,
+ flavor=flavor,
+ include_residuals=include_residuals,
session=session,
)
diff --git a/api/schemas/state.py b/api/schemas/state.py
index 75c4e39..3b9e53e 100644
--- a/api/schemas/state.py
+++ b/api/schemas/state.py
@@ -30,6 +30,7 @@ class WorkstreamTotals(BaseModel):
finished: int = 0
archived: int = 0
total: int = 0
+ residual_open: int = 0
class TaskTotals(BaseModel):
@@ -85,6 +86,7 @@ class StateSummary(BaseModel):
blocked_tasks: list[TaskRead] = []
recent_progress: list[ProgressEventRead]
open_workplans: list[WorkstreamWithDeps] = []
+ residual_open_workplans: list[WorkstreamWithDeps] = []
next_steps: list[NextStep] = []
domains: list[DomainSummary] = []
contribution_counts: dict[str, int] = {}
@@ -98,6 +100,7 @@ class DashboardWorkplanRow(BaseModel):
id: uuid.UUID
title: str
status: str
+ flavor: str | None = None
domain: str = "unknown"
repo_label: str = "unassigned"
workplan_filename: str | None = None
diff --git a/api/schemas/task.py b/api/schemas/task.py
index 0c353f4..31bf85d 100644
--- a/api/schemas/task.py
+++ b/api/schemas/task.py
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from api.models.task import TaskPriority, TaskStatus
from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
from api.task_status import normalize_task_status
+from api.work_record_flavor import WORK_RECORD_FLAVORS, normalize_flavor
class TaskStatusMixin(BaseModel):
@@ -18,9 +19,23 @@ class TaskStatusMixin(BaseModel):
return normalize_task_status(value)
-class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
+class TaskFlavorMixin(BaseModel):
+ @field_validator("flavor", mode="before", check_fields=False)
+ @classmethod
+ def _normalize_flavor(cls, value):
+ flavor = normalize_flavor(value)
+ if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
+ raise ValueError(
+ f"Unknown work-record flavor {flavor!r}; "
+ f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
+ )
+ return flavor
+
+
+class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin):
id: uuid.UUID | None = None
record_id: str | None = None
+ flavor: str | None = None
title: str
description: str | None = None
status: TaskStatus = TaskStatus.todo
@@ -39,8 +54,9 @@ class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
return self
-class TaskUpdate(TaskStatusMixin):
+class TaskUpdate(TaskStatusMixin, TaskFlavorMixin):
title: str | None = None
+ flavor: str | None = None
description: str | None = None
status: TaskStatus | None = None
priority: TaskPriority | None = None
@@ -102,6 +118,7 @@ class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
record_id: str | None = None
+ flavor: str | None = None
title: str
description: str | None = None
status: TaskStatus
diff --git a/api/schemas/workplan.py b/api/schemas/workplan.py
index 4258e5e..31bf49c 100644
--- a/api/schemas/workplan.py
+++ b/api/schemas/workplan.py
@@ -5,6 +5,12 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, field_validator
from api.schemas.workplan_dependency import WorkplanDepStub
+from api.work_record_flavor import (
+ FLAVOR_PROMOTION_REASONS,
+ WORK_RECORD_FLAVORS,
+ normalize_flavor,
+ normalize_promotion_reason,
+)
from api.workplan_status import normalize_workplan_status
WorkplanStatus = Literal[
@@ -28,7 +34,42 @@ class WorkplanStatusMixin(BaseModel):
return normalize_workplan_status(value)
-class WorkplanCreate(WorkplanStatusMixin):
+class WorkplanFlavorMixin(BaseModel):
+ @field_validator("flavor", mode="before", check_fields=False)
+ @classmethod
+ def _normalise_flavor(cls, value):
+ flavor = normalize_flavor(value)
+ if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
+ raise ValueError(
+ f"Unknown work-record flavor {flavor!r}; "
+ f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
+ )
+ return flavor
+
+ @field_validator("flavor_promotion_reason", mode="before", check_fields=False)
+ @classmethod
+ def _normalise_promotion_reason(cls, value):
+ reason = normalize_promotion_reason(value)
+ if reason is not None and reason not in FLAVOR_PROMOTION_REASONS:
+ raise ValueError(
+ f"Unknown flavor_promotion_reason {reason!r}; "
+ f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}"
+ )
+ return reason
+
+ @field_validator("flavor_promoted_from", mode="before", check_fields=False)
+ @classmethod
+ def _normalise_promoted_from(cls, value):
+ flavor = normalize_flavor(value)
+ if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
+ raise ValueError(
+ f"Unknown flavor_promoted_from {flavor!r}; "
+ f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
+ )
+ return flavor
+
+
+class WorkplanCreate(WorkplanStatusMixin, WorkplanFlavorMixin):
id: uuid.UUID | None = None
repo_id: uuid.UUID
topic_id: uuid.UUID | None = None
@@ -40,6 +81,9 @@ class WorkplanCreate(WorkplanStatusMixin):
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
+ flavor: str | None = None
+ flavor_promotion_reason: str | None = None
+ flavor_promoted_from: str | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
@@ -49,7 +93,7 @@ class WorkplanCreate(WorkplanStatusMixin):
repo_goal_id: uuid.UUID | None = None
-class WorkplanUpdate(WorkplanStatusMixin):
+class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin):
title: str | None = None
description: str | None = None
status: WorkplanStatus | None = None
@@ -57,6 +101,9 @@ class WorkplanUpdate(WorkplanStatusMixin):
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
+ flavor: str | None = None
+ flavor_promotion_reason: str | None = None
+ flavor_promoted_from: str | None = None
execution_state: ExecutionState | None = None
launch_mode: LaunchMode | None = None
concurrency_mode: ConcurrencyMode | None = None
@@ -95,6 +142,9 @@ class WorkplanRead(WorkplanStatusMixin):
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
+ flavor: str | None = None
+ flavor_promotion_reason: str | None = None
+ flavor_promoted_from: str | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
diff --git a/api/work_record_flavor.py b/api/work_record_flavor.py
new file mode 100644
index 0000000..5fc9fe3
--- /dev/null
+++ b/api/work_record_flavor.py
@@ -0,0 +1,48 @@
+"""Work-record flavor (STATE-WP-0092).
+
+Flavor is a closed bucket on workplans and tasks, orthogonal to kind and
+status. Unset flavor is not residual.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+WORK_RECORD_FLAVORS: tuple[str, ...] = (
+ "planning",
+ "implementation",
+ "refactoring",
+ "extension",
+ "residual",
+)
+RESIDUAL_FLAVOR = "residual"
+FLAVOR_PROMOTION_REASONS: tuple[str, ...] = ("demand", "risk")
+
+_EMPTY = {"", "~", "null", "none", "nil"}
+
+
+def normalize_flavor(value: Any) -> str | None:
+ if value is None:
+ return None
+ text = str(value).strip().lower()
+ if text in _EMPTY:
+ return None
+ return text
+
+
+def is_known_flavor(value: Any) -> bool:
+ flavor = normalize_flavor(value)
+ return flavor is None or flavor in WORK_RECORD_FLAVORS
+
+
+def is_residual_flavor(value: Any) -> bool:
+ return normalize_flavor(value) == RESIDUAL_FLAVOR
+
+
+def normalize_promotion_reason(value: Any) -> str | None:
+ if value is None:
+ return None
+ text = str(value).strip().lower()
+ if text in _EMPTY:
+ return None
+ return text
diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md
index 16e699f..b9cebe0 100644
--- a/dashboard/src/workstreams.md
+++ b/dashboard/src/workstreams.md
@@ -234,11 +234,13 @@ const _filtersForm = Inputs.form(
domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}),
status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}),
owner: Inputs.text({placeholder: "Owner…", style: "width:120px"}),
+ include_residuals: Inputs.toggle({label: "Include residuals", value: false}),
},
{
- template: ({domain, status, owner}) => html`
+ template: ({domain, status, owner, include_residuals}) => html`
${domain}${status}
${owner}
+ ${include_residuals}
`,
}
);
@@ -253,7 +255,8 @@ const filters = Generators.input(_filtersForm);
const filtered = data.filter(w =>
(filters.domain.length === 0 || filters.domain.includes(w.domain)) &&
(filters.status.length === 0 || filters.status.includes(w.status)) &&
- (!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase()))
+ (!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase())) &&
+ (filters.include_residuals || w.flavor !== "residual")
);
```
diff --git a/docs/work-record-flavor.md b/docs/work-record-flavor.md
new file mode 100644
index 0000000..93f255f
--- /dev/null
+++ b/docs/work-record-flavor.md
@@ -0,0 +1,57 @@
+# Work-record flavor and residual default-exclusion
+
+Status: active (STATE-WP-0092)
+Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Flavor
+
+## Rule
+
+Workplans and tasks carry an optional **flavor** bucket:
+
+`planning` | `implementation` | `refactoring` | `extension` | `residual`
+
+Flavor is not a kind and not a status. Unset flavor is **not** residual.
+
+`flavor: residual` means leftover work that is **not** as valuable as the
+main functional and non-functional requirements. Default State Hub views of
+open work **omit** residuals. Agents must not pick residual workplans or
+residual tasks for implementation unless the flavor has been promoted.
+
+Promotion is a file edit:
+
+```yaml
+flavor: implementation # or planning | refactoring | extension
+flavor_promoted_from: residual
+flavor_promotion_reason: demand # or risk
+```
+
+Then `statehub fix-consistency`. After promotion the record re-enters the
+default view.
+
+`origin: residual` on intakes remains provenance. It does not hide a
+workplan; `flavor` does.
+
+## `depends_on`
+
+Workplan frontmatter `depends_on` is the canonical list of blocker
+**workplan ids**. C-20 indexes it (legacy alias: `depends_on_workplans`).
+Do not use `depends_on_workplans` on new files.
+
+## API
+
+| Surface | Default | Residual access |
+| --- | --- | --- |
+| `GET /workplans/` | includes residuals (full index) | `?flavor=residual` or `?include_residuals=false` to hide |
+| `GET /state/summary` `open_workplans` | omits residuals | `?include_residuals=true` fills `residual_open_workplans`; `totals.workstreams.residual_open` is always the count |
+| `GET /state/next_steps` | never recommends residual work | — |
+| `GET /state/deps` | omits residuals | `?include_residuals=true` |
+
+Unknown flavor values fail closed on write (HTTP 422). Consistency check
+C-36 warns; it does not invent a flavor for historic files.
+
+## Fleet follow-on
+
+- Backfill existing files: `CUST-WP-0072`
+- Fabric graph: `RAIL-FAB-WP-0030`
+- Cross-owner waits: `COORDINATION-WP-0005`
+
+Do not message `ops-warden` for secrets; see credential routing.
diff --git a/docs/workplan-convention.md b/docs/workplan-convention.md
index d7e2196..6c763ac 100644
--- a/docs/workplan-convention.md
+++ b/docs/workplan-convention.md
@@ -17,8 +17,15 @@ repo: state-hub
status: proposed
owner: custodian
topic_slug: custodian
+flavor: planning
+depends_on: []
```
+`flavor` is `planning` | `implementation` | `refactoring` | `extension` |
+`residual`. Unset is not residual. Default State Hub open views omit
+`flavor: residual` until demand or risk promotes it (STATE-WP-0092).
+`depends_on` lists blocker workplan ids; C-20 indexes that field.
+
During extraction, legacy `CUST-WP-*` plans may be bridged or migrated with
their existing `state_hub_workstream_id` values. Write files first, then run
State Hub consistency sync after this repo is registered.
diff --git a/migrations/versions/c6f7a8b9d0e1_work_record_flavor.py b/migrations/versions/c6f7a8b9d0e1_work_record_flavor.py
new file mode 100644
index 0000000..ae0632a
--- /dev/null
+++ b/migrations/versions/c6f7a8b9d0e1_work_record_flavor.py
@@ -0,0 +1,39 @@
+"""work-record flavor on workplans and tasks (STATE-WP-0092)
+
+Flavor is a closed bucket (planning, implementation, refactoring, extension,
+residual), orthogonal to kind and status. Nullable: unset is not residual.
+
+Revision ID: c6f7a8b9d0e1
+Revises: b5e6f7a8c9d0
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "c6f7a8b9d0e1"
+down_revision = "b5e6f7a8c9d0"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("workplans", sa.Column("flavor", sa.String(length=32), nullable=True))
+ op.add_column(
+ "workplans",
+ sa.Column("flavor_promotion_reason", sa.String(length=32), nullable=True),
+ )
+ op.add_column(
+ "workplans",
+ sa.Column("flavor_promoted_from", sa.String(length=32), nullable=True),
+ )
+ op.create_index("ix_workplans_flavor", "workplans", ["flavor"])
+ op.add_column("tasks", sa.Column("flavor", sa.String(length=32), nullable=True))
+ op.create_index("ix_tasks_flavor", "tasks", ["flavor"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_tasks_flavor", table_name="tasks")
+ op.drop_column("tasks", "flavor")
+ op.drop_index("ix_workplans_flavor", table_name="workplans")
+ op.drop_column("workplans", "flavor_promoted_from")
+ op.drop_column("workplans", "flavor_promotion_reason")
+ op.drop_column("workplans", "flavor")
diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py
index 02b39b6..a2078c1 100644
--- a/scripts/consistency_check.py
+++ b/scripts/consistency_check.py
@@ -39,6 +39,9 @@ Checks:
C-33 work-record-index-stale WARN Yes WORK-RECORDS.md missing or stale — generated per-repo index (CUST-WP-0061-T04)
C-34 quality-dor-ready WARN No status=ready without quality_dor DoR-Ok (STATE-WP-0077 soft)
C-35 repo-manager-conformance WARN No Repo Manager flavor/standards contract reports findings
+ C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list
+ C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref
+ C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins)
(finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood)
Usage:
@@ -118,6 +121,13 @@ from api.task_status import ( # noqa: E402
TERMINAL_TASK_STATUSES,
normalize_task_status,
)
+from api.work_record_flavor import ( # noqa: E402
+ FLAVOR_PROMOTION_REASONS,
+ WORK_RECORD_FLAVORS,
+ is_residual_flavor,
+ normalize_flavor,
+ normalize_promotion_reason,
+)
_WORK_RECORD_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
@@ -509,6 +519,36 @@ def _as_list(value: Any) -> list[str]:
return [str(value).strip().strip('"')]
+_TASK_RECORD_ID_RE = re.compile(r"-T\d{2,3}$")
+
+
+def _dedupe_preserve(items: list[str]) -> list[str]:
+ seen: set[str] = set()
+ out: list[str] = []
+ for item in items:
+ if item in seen:
+ continue
+ seen.add(item)
+ out.append(item)
+ return out
+
+
+def _frontmatter_depends_on_workplans(meta: dict) -> list[str]:
+ """Canonical `depends_on` plus legacy `depends_on_workplans` alias."""
+ combined = _as_list(meta.get("depends_on")) + _as_list(meta.get("depends_on_workplans"))
+ return _dedupe_preserve(
+ [item for item in combined if item and not _TASK_RECORD_ID_RE.search(item)]
+ )
+
+
+def _frontmatter_depends_on_tasks(meta: dict) -> list[str]:
+ from_alias = _as_list(meta.get("depends_on_tasks"))
+ from_depends_on = [
+ item for item in _as_list(meta.get("depends_on")) if _TASK_RECORD_ID_RE.search(item)
+ ]
+ return _dedupe_preserve(from_alias + from_depends_on)
+
+
def _as_int_or_none(value: Any) -> int | None:
if value in (None, "", "~", "null", "None", "none"):
return None
@@ -1664,6 +1704,86 @@ def check_repo(
_fix_context={"ws_id": ws_id, "field": "planning_order", "value": planning_order},
)
+ file_flavor = normalize_flavor(meta.get("flavor"))
+ if file_flavor is not None and file_flavor not in WORK_RECORD_FLAVORS:
+ report.add(
+ severity="WARN",
+ check_id="C-36",
+ message=(
+ f"Unknown work-record flavor {file_flavor!r} in '{ws.get('slug')}'; "
+ f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
+ ),
+ file_path=fname,
+ db_id=ws_id,
+ file_value=file_flavor,
+ fixable=False,
+ )
+ file_flavor = None
+ db_flavor = normalize_flavor(ws.get("flavor"))
+ if file_flavor != db_flavor:
+ report.add(
+ severity="WARN", check_id="C-19",
+ message=(
+ f"Flavor drift in '{ws.get('slug')}': "
+ f"file={file_flavor!r} db={db_flavor!r} (file wins)"
+ ),
+ file_path=fname,
+ db_id=ws_id,
+ file_value=file_flavor,
+ db_value=db_flavor,
+ fixable=True,
+ _fix_context={"ws_id": ws_id, "field": "flavor", "value": file_flavor},
+ )
+ file_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason"))
+ if file_reason is not None and file_reason not in FLAVOR_PROMOTION_REASONS:
+ report.add(
+ severity="WARN",
+ check_id="C-36",
+ message=(
+ f"Unknown flavor_promotion_reason {file_reason!r} in '{ws.get('slug')}'; "
+ f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}"
+ ),
+ file_path=fname,
+ db_id=ws_id,
+ file_value=file_reason,
+ fixable=False,
+ )
+ file_reason = None
+ db_reason = normalize_promotion_reason(ws.get("flavor_promotion_reason"))
+ if file_reason != db_reason:
+ report.add(
+ severity="WARN", check_id="C-19",
+ message=(
+ f"Flavor promotion reason drift in '{ws.get('slug')}': "
+ f"file={file_reason!r} db={db_reason!r} (file wins)"
+ ),
+ file_path=fname,
+ db_id=ws_id,
+ file_value=file_reason,
+ db_value=db_reason,
+ fixable=True,
+ _fix_context={
+ "ws_id": ws_id,
+ "field": "flavor_promotion_reason",
+ "value": file_reason,
+ },
+ )
+ if is_residual_flavor(file_flavor):
+ origin = str(meta.get("origin") or "").strip()
+ origin_ref = str(meta.get("origin_ref") or "").strip()
+ if not origin and not origin_ref:
+ report.add(
+ severity="WARN",
+ check_id="C-37",
+ message=(
+ f"Residual workplan '{ws.get('slug')}' has no origin/origin_ref "
+ "provenance (not invented)"
+ ),
+ file_path=fname,
+ db_id=ws_id,
+ fixable=False,
+ )
+
# C-10, C-11, C-12: task-level checks
db_task_by_id: dict[str, dict] = {}
if isinstance(db_tasks, list):
@@ -1688,7 +1808,7 @@ def check_repo(
if dep.get("to_task_id"):
existing_dep_keys.add(("task", dep["to_task_id"], rel))
- for target_wp_id in _as_list(meta.get("depends_on_workplans")):
+ for target_wp_id in _frontmatter_depends_on_workplans(meta):
target_ws_id = workplan_id_to_ws_id.get(target_wp_id)
if not target_ws_id:
report.add(
@@ -1717,7 +1837,7 @@ def check_repo(
},
)
- for target_task_id in _as_list(meta.get("depends_on_tasks")):
+ for target_task_id in _frontmatter_depends_on_tasks(meta):
target_sh_id = task_file_id_to_sh_id.get(target_task_id)
if not target_sh_id:
report.add(
@@ -1849,6 +1969,36 @@ def check_repo(
fixable=True,
_fix_context={"task_id": t_sh_id, "description": file_description},
)
+ file_task_flavor = normalize_flavor(task.get("flavor"))
+ if file_task_flavor is not None and file_task_flavor not in WORK_RECORD_FLAVORS:
+ report.add(
+ severity="WARN",
+ check_id="C-36",
+ message=(
+ f"Unknown work-record flavor {file_task_flavor!r} on task '{t_id}'"
+ ),
+ file_path=f"{fname}#{t_id}",
+ db_id=t_sh_id,
+ file_value=file_task_flavor,
+ fixable=False,
+ )
+ file_task_flavor = None
+ db_task_flavor = normalize_flavor(db_task.get("flavor"))
+ if file_task_flavor != db_task_flavor:
+ report.add(
+ severity="WARN",
+ check_id="C-38",
+ message=(
+ f"Task flavor drift '{t_id}': "
+ f"file={file_task_flavor!r} db={db_task_flavor!r} (file wins)"
+ ),
+ file_path=f"{fname}#{t_id}",
+ db_id=t_sh_id,
+ file_value=file_task_flavor,
+ db_value=db_task_flavor,
+ fixable=True,
+ _fix_context={"task_id": t_sh_id, "flavor": file_task_flavor},
+ )
elif t_id:
# C-11: task exists in file but not linked to DB
ws_status = ws.get("status", "")
@@ -3062,6 +3212,15 @@ def fix_repo(
)
continue
+ create_flavor = normalize_flavor(meta.get("flavor"))
+ if create_flavor is not None and create_flavor not in WORK_RECORD_FLAVORS:
+ create_flavor = None
+ create_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason"))
+ if create_reason is not None and create_reason not in FLAVOR_PROMOTION_REASONS:
+ create_reason = None
+ create_from = normalize_flavor(meta.get("flavor_promoted_from"))
+ if create_from is not None and create_from not in WORK_RECORD_FLAVORS:
+ create_from = None
ws_data = _api_post(api_base, "/workplans", {
"id": desired_ws_id,
"topic_id": topic_id,
@@ -3072,6 +3231,9 @@ def fix_repo(
"owner": str(meta.get("owner", "")).strip() or None,
"planning_priority": str(meta.get("planning_priority", "")).strip() or None,
"planning_order": _as_int_or_none(meta.get("planning_order")),
+ "flavor": create_flavor,
+ "flavor_promotion_reason": create_reason,
+ "flavor_promoted_from": create_from,
})
if ws_data is None or (isinstance(ws_data, dict) and "_error" in ws_data):
last_error = ws_data.get("_error") if isinstance(ws_data, dict) else "no response"
@@ -3116,6 +3278,9 @@ def fix_repo(
if raw_task_id not in (None, "", "~", "null", "None", "none")
else _derived_work_record_uuid(t_id)
)
+ task_flavor = normalize_flavor(task.get("flavor"))
+ if task_flavor is not None and task_flavor not in WORK_RECORD_FLAVORS:
+ task_flavor = None
t_data = _api_post(api_base, "/tasks", {
"id": desired_task_id,
"workplan_id": new_ws_id,
@@ -3124,6 +3289,7 @@ def fix_repo(
"status": t_status,
"priority": t_priority,
"assignee": task.get("assignee") or None,
+ "flavor": task_flavor,
})
if t_data and "_error" not in t_data:
t_db_id = t_data["id"]
@@ -3349,6 +3515,19 @@ def fix_repo(
f"C-22 FAILED: task {task_id[:8]}… description update: {result['_error']}"
)
+ elif issue.check_id == "C-38":
+ task_id = ctx["task_id"]
+ flavor = ctx.get("flavor")
+ result = _api_patch(api_base, f"/tasks/{task_id}", {"flavor": flavor})
+ if result is not None and "_error" not in result:
+ report.fixes_applied.append(
+ f"C-38 fixed: task {task_id[:8]}… flavor → {flavor!r}"
+ )
+ elif result is not None:
+ report.fixes_applied.append(
+ f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}"
+ )
+
elif issue.check_id == "C-15":
# T03 — writeback: DB is ahead of file — patch file to match DB.
if no_writeback:
diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py
index 2d74296..7eeb439 100644
--- a/tests/test_consistency_check.py
+++ b/tests/test_consistency_check.py
@@ -1328,6 +1328,173 @@ class TestC20DependencyDetection:
assert "C-20" not in [issue.check_id for issue in report.issues]
+ def test_depends_on_alias_satisfies_workplan_dependency(self, tmp_path, monkeypatch):
+ repo = tmp_path / "repo"
+ workplans = repo / "workplans"
+ workplans.mkdir(parents=True)
+ (workplans / "STATE-WP-0001-base.md").write_text(
+ "---\n"
+ "id: STATE-WP-0001\n"
+ "title: Base\n"
+ "domain: financials\n"
+ "repo: demo-repo\n"
+ "status: active\n"
+ "state_hub_workstream_id: \"base-ws\"\n"
+ "---\n\n",
+ encoding="utf-8",
+ )
+ (workplans / "STATE-WP-0002-dependent.md").write_text(
+ "---\n"
+ "id: STATE-WP-0002\n"
+ "title: Dependent\n"
+ "domain: financials\n"
+ "repo: demo-repo\n"
+ "status: active\n"
+ "state_hub_workstream_id: \"dependent-ws\"\n"
+ "depends_on:\n"
+ " - STATE-WP-0001\n"
+ "---\n\n",
+ encoding="utf-8",
+ )
+
+ def fake_get(_api_base, path, params=None, **_kwargs):
+ if path == "/repos/demo-repo":
+ import socket
+
+ return {
+ "id": "repo-1",
+ "slug": "demo-repo",
+ "local_path": str(repo),
+ "host_paths": {socket.gethostname(): str(repo)},
+ "domain_slug": "financials",
+ }
+ if path == "/workplans/base-ws":
+ return {"id": "base-ws", "repo_id": "repo-1", "slug": "state-wp-0001", "title": "Base", "status": "active"}
+ if path == "/workplans/dependent-ws":
+ return {"id": "dependent-ws", "repo_id": "repo-1", "slug": "state-wp-0002", "title": "Dependent", "status": "active"}
+ if path == "/tasks" and params and params.get("workstream_id") in {"base-ws", "dependent-ws"}:
+ return []
+ if path == "/workplans/base-ws/dependencies":
+ return []
+ if path == "/workplans/dependent-ws/dependencies":
+ return [
+ {
+ "id": "dep-1",
+ "from_workplan_id": "dependent-ws",
+ "to_workplan_id": "base-ws",
+ "to_task_id": None,
+ "relationship_type": "blocks",
+ }
+ ]
+ if path == "/workplans" and params == {"repo_id": "repo-1"}:
+ return []
+ return []
+
+ monkeypatch.setattr("consistency_check._api_get", fake_get)
+
+ report = check_repo("http://unused", "demo-repo")
+
+ assert "C-20" not in [issue.check_id for issue in report.issues]
+
+
+class TestFlavorChecks:
+ def test_unknown_flavor_warns_c36(self, tmp_path, monkeypatch):
+ repo = tmp_path / "repo"
+ workplans = repo / "workplans"
+ workplans.mkdir(parents=True)
+ (workplans / "STATE-WP-0001-odd.md").write_text(
+ "---\n"
+ "id: STATE-WP-0001\n"
+ "title: Odd\n"
+ "domain: financials\n"
+ "repo: demo-repo\n"
+ "status: active\n"
+ "flavor: sidetrack\n"
+ "state_hub_workstream_id: \"odd-ws\"\n"
+ "---\n\n",
+ encoding="utf-8",
+ )
+
+ def fake_get(_api_base, path, params=None, **_kwargs):
+ if path == "/repos/demo-repo":
+ import socket
+
+ return {
+ "id": "repo-1",
+ "slug": "demo-repo",
+ "local_path": str(repo),
+ "host_paths": {socket.gethostname(): str(repo)},
+ "domain_slug": "financials",
+ }
+ if path == "/workplans/odd-ws":
+ return {
+ "id": "odd-ws",
+ "repo_id": "repo-1",
+ "slug": "state-wp-0001",
+ "title": "Odd",
+ "status": "active",
+ }
+ if path == "/tasks":
+ return []
+ if path.endswith("/dependencies"):
+ return []
+ if path == "/workplans":
+ return []
+ return []
+
+ monkeypatch.setattr("consistency_check._api_get", fake_get)
+ report = check_repo("http://unused", "demo-repo")
+ assert "C-36" in [issue.check_id for issue in report.issues]
+
+ def test_residual_without_origin_warns_c37(self, tmp_path, monkeypatch):
+ repo = tmp_path / "repo"
+ workplans = repo / "workplans"
+ workplans.mkdir(parents=True)
+ (workplans / "STATE-WP-0001-left.md").write_text(
+ "---\n"
+ "id: STATE-WP-0001\n"
+ "title: Left\n"
+ "domain: financials\n"
+ "repo: demo-repo\n"
+ "status: active\n"
+ "flavor: residual\n"
+ "state_hub_workstream_id: \"left-ws\"\n"
+ "---\n\n",
+ encoding="utf-8",
+ )
+
+ def fake_get(_api_base, path, params=None, **_kwargs):
+ if path == "/repos/demo-repo":
+ import socket
+
+ return {
+ "id": "repo-1",
+ "slug": "demo-repo",
+ "local_path": str(repo),
+ "host_paths": {socket.gethostname(): str(repo)},
+ "domain_slug": "financials",
+ }
+ if path == "/workplans/left-ws":
+ return {
+ "id": "left-ws",
+ "repo_id": "repo-1",
+ "slug": "state-wp-0001",
+ "title": "Left",
+ "status": "active",
+ "flavor": "residual",
+ }
+ if path == "/tasks":
+ return []
+ if path.endswith("/dependencies"):
+ return []
+ if path == "/workplans":
+ return []
+ return []
+
+ monkeypatch.setattr("consistency_check._api_get", fake_get)
+ report = check_repo("http://unused", "demo-repo")
+ assert "C-37" in [issue.check_id for issue in report.issues]
+
class TestC06WorkstreamCreation:
def test_fix_repo_bootstraps_legacy_ids_only_into_empty_projection(
diff --git a/tests/test_work_record_flavor.py b/tests/test_work_record_flavor.py
new file mode 100644
index 0000000..c81e910
--- /dev/null
+++ b/tests/test_work_record_flavor.py
@@ -0,0 +1,136 @@
+"""STATE-WP-0092: flavor indexing, residual default-exclusion, depends_on alias."""
+from __future__ import annotations
+
+import pytest
+
+from tests.test_routers_core import (
+ _create_domain,
+ _create_repo,
+ _create_task,
+ _create_topic,
+ _create_workstream,
+)
+
+
+@pytest.mark.asyncio
+async def test_unknown_flavor_rejected_on_create(client):
+ await _create_domain(client)
+ topic = await _create_topic(client)
+ repo = await _create_repo(client)
+ r = await client.post(
+ "/workplans/",
+ json={
+ "repo_id": repo["id"],
+ "topic_id": topic["id"],
+ "slug": "bad-flavor",
+ "title": "Bad flavor",
+ "flavor": "sidetrack",
+ },
+ )
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_flavor_round_trip_and_list_filters(client):
+ await _create_domain(client)
+ topic = await _create_topic(client)
+ relevant = await _create_workstream(
+ client, topic["id"], slug="rel-wp", title="Relevant", flavor="implementation"
+ )
+ residual = await _create_workstream(
+ client, topic["id"], slug="res-wp", title="Leftover", flavor="residual"
+ )
+ unspecified = await _create_workstream(
+ client, topic["id"], slug="unset-wp", title="Historic"
+ )
+
+ listed = await client.get("/workplans/")
+ ids = {row["id"] for row in listed.json()}
+ assert relevant["id"] in ids
+ assert residual["id"] in ids
+ assert unspecified["id"] in ids
+
+ hidden = await client.get("/workplans/", params={"include_residuals": "false"})
+ hidden_ids = {row["id"] for row in hidden.json()}
+ assert relevant["id"] in hidden_ids
+ assert unspecified["id"] in hidden_ids
+ assert residual["id"] not in hidden_ids
+
+ only_residual = await client.get("/workplans/", params={"flavor": "residual"})
+ only_ids = {row["id"] for row in only_residual.json()}
+ assert only_ids == {residual["id"]}
+
+ got = await client.get(f"/workplans/{residual['id']}")
+ assert got.json()["flavor"] == "residual"
+
+
+@pytest.mark.asyncio
+async def test_summary_omits_residuals_unless_included(client):
+ await _create_domain(client)
+ topic = await _create_topic(client)
+ relevant = await _create_workstream(
+ client, topic["id"], slug="sum-rel", title="Do this", flavor="planning"
+ )
+ residual = await _create_workstream(
+ client, topic["id"], slug="sum-res", title="Later maybe", flavor="residual"
+ )
+
+ summary = (await client.get("/state/summary")).json()
+ open_ids = {row["id"] for row in summary["open_workplans"]}
+ assert relevant["id"] in open_ids
+ assert residual["id"] not in open_ids
+ assert summary["totals"]["workstreams"]["residual_open"] == 1
+ assert summary["residual_open_workplans"] == []
+
+ included = (await client.get("/state/summary", params={"include_residuals": "true"})).json()
+ residual_ids = {row["id"] for row in included["residual_open_workplans"]}
+ assert residual["id"] in residual_ids
+ assert relevant["id"] not in residual_ids
+ open_ids_still = {row["id"] for row in included["open_workplans"]}
+ assert residual["id"] not in open_ids_still
+
+ deps = (await client.get("/state/deps")).json()
+ dep_ids = {row["id"] for row in deps}
+ assert relevant["id"] in dep_ids
+ assert residual["id"] not in dep_ids
+
+
+@pytest.mark.asyncio
+async def test_next_steps_skips_residual_workplan(client):
+ await _create_domain(client)
+ topic = await _create_topic(client)
+ blocker = await _create_workstream(
+ client, topic["id"], slug="done-dep", title="Finished dep", status="finished"
+ )
+ residual = await _create_workstream(
+ client, topic["id"], slug="res-ready", title="Residual leftover", flavor="residual"
+ )
+ r = await client.post(
+ f"/workplans/{residual['id']}/dependencies/",
+ json={"to_workstream_id": blocker["id"]},
+ )
+ assert r.status_code == 201
+ await _create_task(client, residual["id"], title="Do not start this")
+
+ steps = (await client.get("/state/next_steps")).json()
+ assert all(step.get("workplan_id") != residual["id"] for step in steps)
+ assert all("Residual leftover" not in (step.get("message") or "") for step in steps)
+
+
+@pytest.mark.asyncio
+async def test_task_flavor_round_trip(client):
+ await _create_domain(client)
+ topic = await _create_topic(client)
+ ws = await _create_workstream(client, topic["id"], slug="task-flavor-wp")
+ r = await client.post(
+ "/tasks/",
+ json={"workplan_id": ws["id"], "title": "Leftover task", "flavor": "residual"},
+ )
+ assert r.status_code == 201
+ assert r.json()["flavor"] == "residual"
+ patched = await client.patch(
+ f"/tasks/{r.json()['id']}",
+ json={"flavor": "implementation", "suppress_token_event": True},
+ )
+ assert patched.status_code == 200
+ assert patched.json()["flavor"] == "implementation"
diff --git a/workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md b/workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md
index 22fe72b..9bc6f53 100644
--- a/workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md
+++ b/workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md
@@ -4,7 +4,7 @@ type: workplan
title: "Workplan flavor, depends_on indexing, and residual default-exclusion"
domain: infotech
repo: state-hub
-status: proposed
+status: finished
owner: grok
topic_slug: infotech
flavor: planning
@@ -62,7 +62,7 @@ parsing unknown frontmatter keys.
```task
id: STATE-WP-0092-T01
-status: todo
+status: done
priority: high
state_hub_task_id: "c5c967ec-6f4c-541e-957d-7ae9489fbf6d"
```
@@ -88,7 +88,7 @@ live leftover is equal open demand.
```task
id: STATE-WP-0092-T02
-status: todo
+status: done
priority: high
depends_on: [STATE-WP-0092-T01]
state_hub_task_id: "63d9c134-0b2b-592b-a7df-a58eaa3fdc99"
@@ -110,7 +110,7 @@ and C-20 no longer requires the unused `depends_on_workplans` spelling.
```task
id: STATE-WP-0092-T03
-status: todo
+status: done
priority: high
depends_on: [STATE-WP-0092-T02]
state_hub_task_id: "0d53713b-d6f0-597d-8f87-9f86e5ca2235"
@@ -136,7 +136,7 @@ it.
```task
id: STATE-WP-0092-T04
-status: todo
+status: done
priority: medium
depends_on: [STATE-WP-0092-T03]
state_hub_task_id: "764a4b17-5119-572a-8aa1-581d719f07fa"
@@ -156,7 +156,7 @@ agent snippet exists for later instruction rollout.
```task
id: STATE-WP-0092-T05
-status: todo
+status: done
priority: high
depends_on: [STATE-WP-0092-T02, STATE-WP-0092-T03]
state_hub_task_id: "ab74d06b-acc4-52da-ac20-ac564ca8a7ed"
@@ -171,3 +171,9 @@ flavor — that is `CUST-WP-0072`.
Done when the new tests are green and `fix-consistency` on this repo
does not warn on this file’s advisory `flavor:` / `depends_on` keys.
+
+2026-09-14: T01–T05 landed in repo. Canon v0.2 flavor list is machine-checkable.
+Hub indexes `flavor` / `depends_on`, default summary/next_steps/deps omit
+`flavor: residual`, and C-20 accepts `depends_on`. Live primary views change
+after alembic `c6f7a8b9d0e1` is applied and this revision is deployed. Until
+then CUST-WP-0072 should wait.