Index workplan flavor and omit residuals from default views.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 32s

STATE-WP-0092: persist flavor from files, treat depends_on as the C-20
canonical edge, exclude flavor=residual from summary/next_steps/deps
unless include_residuals is set. Live primary still needs the alembic
revision applied.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
This commit is contained in:
tegwick 2026-09-14 15:28:58 +02:00
parent ea11451e9c
commit ddc3338541
18 changed files with 852 additions and 25 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

48
api/work_record_flavor.py Normal file
View file

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

View file

@ -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`<div class="filter-bar">
template: ({domain, status, owner, include_residuals}) => html`<div class="filter-bar">
${domain}${status}
<div class="filter-text-input">${owner}</div>
${include_residuals}
</div>`,
}
);
@ -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")
);
```

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 files advisory `flavor:` / `depends_on` keys.
2026-09-14: T01T05 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.