Files
ss-tools/specs/034-task-status-center/contracts/modules.md
busya 33ee976c48 feat(reports): Task Status Center — unified /reports dashboard
Страница /reports трансформирована в Центр статусов задач:

Backend:
- GET /api/reports/summary — агрегированные счётчики тип×статус (5 корзин)
- GET/PUT /api/settings/reports — глобальные настройки отчётов
- _filter_tasks_by_rbac() — row-level фильтрация по роли
- normalize_task_report: LLM-валидация с ошибками → FAILED/PARTIAL
- get_summary(): 5 корзин pending/running/awaiting_input/success/failed

Frontend:
- TaskCenterModel.svelte.ts (400 строк) — Screen Model
- SummaryPanel — сводная панель с цветовым кодированием и active filter
- ReportCard — humanized labels, duration, task_id, failed border
- FilterBar — search + sort + time range с label'ами
- Pagination — showingText, уникальные id для select
- Quick views: «Упавшие», «В работе», «Успешные»
- TaskDrawer: scroll-to-error, footer скрыт для terminal, «Н/Д» fix

Тесты: 48 backend + 38 frontend (2521 всего)
Build:   Console errors: 0
2026-07-02 18:53:58 +03:00

475 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#region ModuleContracts [C:5] [TYPE ADR] [SEMANTICS contracts, modules, grace, task-status-center, backend, frontend]
@BRIEF GRACE-Poly v2.6 module contracts for the Task Status Center feature — backend API, service, and frontend Model/Component contracts with complexity anchors, decision memory, and cross-stack relations.
@RATIONALE Contracts are the executable specification bridging spec.md ↔ code. Without them, agents optimize for token likelihood rather than spec compliance.
@RELATION DEPENDS_ON -> [ReportModels]
@RELATION DEPENDS_ON -> [TaskCenterModel]
@RELATION DEPENDS_ON -> [TaskDrawer]
## 1. Complexity Inventory
| Module | Language | Complexity | Justification |
|--------|----------|:----------:|---------------|
| `Reports.Summary` (api route) | Python | C4 | Multi-step: RBAC check, task loading, normalization, aggregation. Side effects: DB read. |
| `Reports.SummaryService` (service) | Python | C4 | Stateful: accesses TaskManager singleton, applies RBAC filtering. |
| `Reports.List` (api route, enhanced) | Python | C4 | Enhanced with RBAC row-filtering. Existing C3→C4. |
| `TaskCenterModel` (model) | TypeScript/Svelte | C4 | Stateful: 15+ atoms, WebSocket lifecycle, debounce, URL sync. Side effects: WS connect/disconnect, REST calls. |
| `SummaryPanel` (component) | Svelte 5 | C4 | WebSocket-reactive derived display. UX states: idle, loading, empty, disconnected. Side effects: click → filter. |
| `FilterBar` (component) | Svelte 5 | C3 | Multi-select inputs, search, time range. No side effects beyond model mutation. |
| `TaskList` (component) | Svelte 5 | C4 | Pagination, sort, click→drawer. UX states: loading, empty, error. |
| `TaskCenterReportsPage` (page) | Svelte 5 | C4 | Page-level orchestration: Model init, layout, UX state machine. |
| `ReportsTypes` (types) | TypeScript | C2 | DTO definitions only. |
---
## 2. Backend Contracts
### 2.1 Reports.SummaryRouter [C:4] [TYPE Module]
```python
# backend/src/api/routes/reports.py (ADD to existing)
# #region Reports.SummaryRouter [C:4] [TYPE Module] [SEMANTICS api, reports, summary, task-status-center]
# @ingroup Reports
# @BRIEF Task summary endpoint — aggregated counts by type × status with RBAC filtering.
# @RELATION CALLS -> [Reports.SummaryService]
# @RELATION DEPENDS_ON -> [TaskSummary]
# @RELATION DEPENDS_ON -> [Auth.Dependencies]
# @RATIONALE Separate endpoint from list_reports (R1) — summary is a lightweight aggregation suitable for independent caching.
# @REJECTED Combining summary into list_reports header — rejected because it bloats paginated list responses and doubles server work.
# @REJECTED WebSocket-only summary delivery — rejected because initial page load needs guaranteed REST delivery (SC-001).
@router.get("/summary", response_model=TaskSummary)
async def get_task_summary(
current_user: User = Depends(get_current_user),
task_manager: TaskManager = Depends(get_task_manager),
clean_release_repo=Depends(get_clean_release_repository),
) -> TaskSummary:
"""
Get aggregated task summary by type and status.
Permission: tasks:READ (checked via get_current_user).
RBAC filtering: admin sees all, analyst sees own+system, viewer sees own only.
"""
...
# #endregion Reports.SummaryRouter
```
### 2.2 Reports.SummaryService [C:4] [TYPE Module]
```python
# backend/src/services/reports/report_service.py (ADD method)
# #region Reports.SummaryService [C:4] [TYPE Module] [SEMANTICS service, summary, aggregation, rbac, task-status-center]
# @ingroup Reports
# @BRIEF Compute aggregated task summary with RBAC-aware filtering.
# @RELATION CALLS -> [TaskManager.get_all_tasks]
# @RELATION CALLS -> [Reports.Normalizer]
# @PRE task_manager is initialized and has tasks loaded.
# @PRE current_user is an authenticated User with tasks:READ permission.
# @POST Returns TaskSummary with counts filtered by user's role.
# @SIDE_EFFECT None (read-only aggregation).
# @RATIONALE RBAC filtering happens here (R4) — service layer, not API layer, keeps business logic testable.
# @REJECTED Filtering at TaskGraph level — rejected because graph is a data structure, not an authorization boundary.
def get_summary(self, current_user: User) -> TaskSummary:
"""
Compute aggregated task counts by (task_type × status).
RBAC rules:
- admin: all tasks
- analyst: own tasks + tasks with user_id=None (system)
- viewer: own tasks only
"""
...
# #endregion Reports.SummaryService
```
### 2.3 Reports.ListEnhanced [C:4] [TYPE Module]
```python
# backend/src/api/routes/reports.py (MODIFY existing)
# #region Reports.ListEnhanced [C:4] [TYPE Module] [SEMANTICS api, reports, list, rbac, task-status-center]
# @ingroup Reports
# @BRIEF Enhanced report list endpoint with RBAC row-level filtering.
# @PRE current_user is authenticated.
# @POST Returns ReportCollection filtered by user role.
# @RATIONALE Enhancement of existing C3 endpoint to C4 — adds RBAC filtering per TSC-FR-009.
# @REJECTED Separate admin/user endpoints — rejected because single endpoint with role-based filtering avoids route duplication.
@router.get("", response_model=ReportCollection)
async def list_reports(
# ... existing params ...
current_user: User = Depends(get_current_user),
# ...
) -> ReportCollection:
# Unchanged signature, enhanced implementation:
# - task_filter applies RBAC before normalization
# - sort_by now accepts "created_at"
...
# #endregion Reports.ListEnhanced
```
### 2.4 RBAC Task Filter [C:3] [TYPE Function]
```python
# backend/src/services/reports/report_service.py (ADD helper)
# #region Reports.RbacTaskFilter [C:3] [TYPE Function] [SEMANTICS service, rbac, filter, task-status-center]
# @ingroup Reports
# @BRIEF Filter tasks by user role for RBAC compliance.
# @PRE current_user is an authenticated User object.
# @POST Returns list of tasks visible to the user.
# @RATIONALE Extracted as a pure function for testability — no side effects, no service dependencies.
# @REJECTED Inline filtering in list_reports — rejected because it mixes authorization with query logic, making unit testing harder.
def _filter_tasks_by_rbac(tasks: list[Task], current_user: User) -> list[Task]:
"""
Filter tasks based on user role.
Rules (from TSC-FR-009):
- admin → all tasks
- analyst → own tasks + system tasks (user_id=None)
- viewer → own tasks only
"""
if any(role.name == "Admin" or getattr(role, "is_admin", False) for role in current_user.roles):
return tasks
if any(role.name == "analyst" for role in current_user.roles if not getattr(role, "is_admin", False)):
return [t for t in tasks if t.user_id == current_user.id or t.user_id is None]
# viewer
return [t for t in tasks if t.user_id == current_user.id]
# #endregion Reports.RbacTaskFilter
```
---
## 3. Frontend Contracts
### 3.1 TaskCenterModel [C:4] [TYPE Model]
```typescript
// frontend/src/lib/models/TaskCenterModel.svelte.ts (NEW FILE)
// #region TaskCenterModel [C:4] [TYPE Model] [SEMANTICS model, task-center, summary, filtering, websocket, task-status-center]
// @BRIEF Screen Model for the Task Status Center — aggregates summary, task list, filtering, WebSocket subscription, and Task Drawer integration.
// @LAYER Store
// @PRE Auth token in localStorage, user authenticated via root +layout.svelte.
// @INVARIANT Active tasks (pending, running, awaiting_input) are always sorted before completed tasks.
// @INVARIANT visibleSummary excludes types in disabledTypes (admin) AND not in visibleTypes (operator).
// @INVARIANT DECOMPOSITION GATE: 400 lines, 40 methods (ADR-0010).
// @STATE tasks, summary, filters, screenState, wsConnected, selectedTaskId, page, pageSize, disabledTypes, visibleTypes, lastUpdated
// @ACTION loadInitialData, applyFilter, clearFilters, selectTask, refreshSummary, connectWebSocket, disconnectWebSocket, destroy, toggleTypeVisibility
// @RELATION CALLS -> [ReportsApi]
// @RELATION CALLS -> [ApiModule.getTaskEventsWsUrl]
// @RELATION BINDS_TO -> [TaskDrawerStore]
// @RELATION BINDS_TO -> [SummaryPanel]
// @RELATION BINDS_TO -> [FilterBar]
// @RELATION BINDS_TO -> [TaskList]
// @DATA_CONTRACT Input: TaskCenterFilter → Output: filteredTasks: TaskReport[], visibleSummary: TaskSummary
// @POST All public methods that mutate state trigger $derived recomputation.
// @POST WebSocket connection lifecycle is managed: connect on mount, disconnect on destroy.
// @SIDE_EFFECT Connects to /ws/task-events WebSocket. Calls fetch API. Updates browser URL via replaceState.
// @SIDE_EFFECT Opens TaskDrawer via taskDrawerStore.openDrawerForTask(). Reads/writes localStorage for visibleTypes.
// @RATIONALE Single model (R3) chosen because the 3 user stories are tightly coupled — summary, filtering, and list share the same task dataset. Premature decomposition would add indirection without benefit.
// @REJECTED Submodels from day one — rejected because estimated model size is ~250 lines, well below ADR-0010 400-line threshold.
// @REJECTED Global store — rejected because Task Center state is page-scoped, not cross-route.
// @REJECTED Polling-based refresh — rejected because spec requires real-time WebSocket updates (SC-002).
export class TaskCenterModel {
// === ATOMS ===
tasks: TaskReport[] = $state([]);
summary: TaskSummary | null = $state(null);
filters: TaskCenterFilter = $state({ ...DEFAULT_FILTERS });
screenState: ScreenState = $state('loading');
wsConnected: boolean = $state(false);
selectedTaskId: string | null = $state(null);
page: number = $state(1);
pageSize: number = $state(20);
disabledTypes: TaskType[] = $state([]); // admin-disabled types (from backend)
visibleTypes: TaskType[] = $state(loadVisibleTypes()); // operator preference (localStorage)
lastUpdated: Date | null = $state(null);
// === DERIVED ===
get filteredTasks(): TaskReport[] { ... } // filter + sort (active first)
get visibleSummary(): TaskSummary | null { ... } // exclude disabledTypes AND not in visibleTypes
get isEmpty(): boolean { ... }
get isFilteredEmpty(): boolean { ... }
get hasActiveFilters(): boolean { ... }
get totalPages(): number { ... }
get hasNextPage(): boolean { ... }
// === LIFECYCLE ===
async loadInitialData(initialFilters?: Partial<TaskCenterFilter>): Promise<void> { ... }
connectWebSocket(): void { ... }
disconnectWebSocket(): void { ... }
destroy(): void { ... }
// === ACTIONS ===
applyFilter(partial: Partial<TaskCenterFilter>): void { ... }
clearFilters(): void { ... }
selectTask(taskId: string): void { ... }
async refreshSummary(): Promise<void> { ... }
toggleTypeVisibility(type: TaskType): void { ... } // localStorage persist
// === PRIVATE METHODS ===
private _onWsMessage(event: MessageEvent): void { ... }
private _flushEventBuffer(): void { ... } // 100ms debounce
private _scheduleReconnect(): void { ... } // exponential backoff
private _syncFiltersToUrl(): void { ... } // replaceState, 300ms debounce
private _buildQueryString(): string { ... }
}
// #endregion TaskCenterModel
```
### 3.2 SummaryPanel [C:4] [TYPE Component]
```svelte
<!-- frontend/src/lib/components/reports/SummaryPanel.svelte (NEW FILE) -->
<!-- #region SummaryPanel [C:4] [TYPE Component] [SEMANTICS component, summary, dashboard, websocket-reactive, task-status-center] -->
<!-- @BRIEF Summary dashboard panel — grid of clickable cards showing task counts by type × status. -->
<!-- @LAYER UI Component -->
<!-- @UX_STATE idle: cards rendered normally, clickable -->
<!-- @UX_STATE loading: skeleton placeholders (4 cards with pulse animation) -->
<!-- @UX_STATE empty: all cards show zero counts -->
<!-- @UX_STATE disconnected: last known counts visible, opacity reduced, "disconnected" badge -->
<!-- @UX_STATE reconnecting: last known counts visible, yellow pulsing indicator -->
<!-- @UX_FEEDBACK Count change: number animates (CSS transition). Card click: emits filter event. -->
<!-- @UX_RECOVERY Disconnected: auto-reconnect indicator. Click → manual reconnect button. -->
<!-- @UX_REACTIVITY Props: summary: TaskSummary | null, screenState: ScreenState. Derived: by_type grid. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
<!-- @PRE summary is non-null when screenState is 'ready' or 'disconnected'. -->
<!-- @POST Clicking a card calls onFilterByTypeAndStatus(taskType, status). -->
<!-- @RATIONALE Cards are clickable per spec US2: "click on summary card → filter list by that type×status". -->
<script lang="ts">
import type { TaskSummary, TaskType, ReportStatus, ScreenState } from '$types/reports';
import { Card } from '$lib/ui';
interface Props {
summary: TaskSummary | null;
screenState: ScreenState;
onFilterByTypeAndStatus: (taskType: TaskType, status: ReportStatus) => void;
}
let { summary, screenState, onFilterByTypeAndStatus }: Props = $props();
</script>
{#if screenState === 'loading'}
<!-- skeleton -->
{:else if summary}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3">
{#each summary.by_type as typeSummary}
{#each STATUS_KEYS as status}
<Card padding="sm" onclick={() => onFilterByTypeAndStatus(typeSummary.task_type, status)}>
<!-- icon + count + status badge -->
</Card>
{/each}
{/each}
</div>
{/if}
<!-- #endregion SummaryPanel -->
```
### 3.3 TaskCenterReportsPage [C:4] [TYPE Page]
```svelte
<!-- frontend/src/routes/reports/+page.svelte (REWRITE from existing) -->
<!-- #region TaskCenterReportsPage [C:4] [TYPE Page] [SEMANTICS page, reports, task-center, layout, task-status-center] -->
<!-- @BRIEF Task Status Center page — redesign of /reports with summary dashboard, real-time updates, and enhanced filtering. -->
<!-- @LAYER Page -->
<!-- @UX_STATE loading: full-page skeleton -->
<!-- @UX_STATE empty: "No tasks" empty state with navigation hints -->
<!-- @UX_STATE ready: summary panel + filter bar + task list -->
<!-- @UX_STATE disconnected: data visible with stale indicator + reconnect banner -->
<!-- @UX_STATE error: error banner + retry button -->
<!-- @UX_REACTIVITY Model: TaskCenterModel instance. Page is thin render layer. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
<!-- @RELATION BINDS_TO -> [SummaryPanel] -->
<!-- @RELATION BINDS_TO -> [FilterBar] -->
<!-- @RELATION BINDS_TO -> [TaskList] -->
<!-- @RELATION CALLS -> [TaskDrawer] -->
<!-- @PRE User is authenticated (enforced by root +layout.svelte). -->
<!-- @POST Page renders summary, filters, and task list. WebSocket connects automatically. -->
<!-- @SIDE_EFFECT Initializes TaskCenterModel on mount. Connects WebSocket. Destroys on unmount. -->
<!-- @RATIONALE Thin render layer per ADR-0006 model-first pattern — all state and logic lives in TaskCenterModel. -->
<script lang="ts">
import { TaskCenterModel } from '$lib/models/TaskCenterModel.svelte.ts';
import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte';
import FilterBar from '$lib/components/reports/FilterBar.svelte';
import TaskList from '$lib/components/reports/TaskList.svelte';
import { PageHeader, Button } from '$lib/ui';
import { onMount } from 'svelte';
let { data } = $props();
const m = new TaskCenterModel();
onMount(() => {
m.loadInitialData(data.initialFilters);
});
// $effect for cleanup when component unmounts
$effect(() => {
return () => m.destroy();
});
</script>
<PageHeader title="Центр статусов">
<Button variant="ghost" onclick={() => m.refreshSummary()}>Обновить</Button>
</PageHeader>
{#if m.screenState === 'disconnected'}
<div class="bg-warning-light border border-warning-ring text-warning p-3 rounded-lg mb-4">
Соединение потеряно. Попытка переподключения...
<Button variant="secondary" onclick={() => m.connectWebSocket()}>Подключиться сейчас</Button>
</div>
{/if}
<SummaryPanel
summary={m.summary}
screenState={m.screenState}
onFilterByTypeAndStatus={(type, status) => m.applyFilter({ task_types: [type], statuses: [status] })}
/>
<FilterBar filters={m.filters} onApplyFilter={m.applyFilter} onClearFilters={m.clearFilters} />
<TaskList
tasks={m.filteredTasks}
screenState={m.screenState}
selectedTaskId={m.selectedTaskId}
page={m.page}
onSelectTask={m.selectTask}
onPageChange={(p) => { m.page = p; }}
/>
<!-- #endregion TaskCenterReportsPage -->
```
### 3.4 ReportsApiClient [C:3] [TYPE Module]
```typescript
// frontend/src/lib/api/reports.ts (ENHANCE existing)
// #region ReportsApiClient [C:3] [TYPE Module] [SEMANTICS api, client, reports, typed, task-status-center]
// @BRIEF Typed API client for reports endpoints including the new summary endpoint.
// @LAYER API Client
// @RELATION CALLS -> [ApiModule.fetchApi]
// @RELATION CALLS -> [ApiModule.getTaskEventsWsUrl]
// @DATA_CONTRACT Input: ReportQuery → Output: ReportCollection
// @DATA_CONTRACT Input: (none) → Output: TaskSummary
// @PRE Auth token exists in localStorage.
// @POST All responses are typed (replaces generic <T>).
// EXISTING — add types
import type { TaskReport, ReportCollection, ReportDetailView, ReportQuery } from '$types/reports';
// NEW
import type { TaskSummary } from '$types/reports';
/** Fetch aggregated task summary. */
export async function getReportsSummary(): Promise<TaskSummary> {
const response = await api.fetchApi('/reports/summary');
return response.json();
}
// EXISTING — enhance with types
export async function getReports(options: Partial<ReportQuery>): Promise<ReportCollection> {
const query = buildReportQueryString(options);
const response = await api.fetchApi(`/reports${query}`);
return response.json();
}
export async function getReportDetail(reportId: string): Promise<ReportDetailView> {
const response = await api.fetchApi(`/reports/${reportId}`);
return response.json();
}
// #endregion ReportsApiClient
```
### 3.5 FilterBar [C:3] [TYPE Component]
```svelte
<!-- frontend/src/lib/components/reports/FilterBar.svelte (NEW FILE — replaces inline filters in +page.svelte) -->
<!-- #region FilterBar [C:3] [TYPE Component] [SEMANTICS component, filter, search, multi-select, task-status-center] -->
<!-- @BRIEF Horizontal filter bar: task type multi-select, status multi-select, text search, time range, sort controls. -->
<!-- @LAYER UI Component -->
<!-- @UX_STATE default: all filters empty, showing all tasks -->
<!-- @UX_STATE filtered: active filters visible with "clear" button -->
<!-- @UX_FEEDBACK Filter change: immediate visual indicator on active filter badges -->
<!-- @UX_REACTIVITY Props: filters: TaskCenterFilter, onApplyFilter(), onClearFilters(). Pure presentation. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
<!-- @PRE filters is a valid TaskCenterFilter object. -->
<!-- @POST Calls onApplyFilter with partial filter updates. -->
<script lang="ts">
import { Button, Select, Input } from '$lib/ui';
import type { TaskCenterFilter, TaskType, ReportStatus } from '$types/reports';
// ... filter UI: multi-select dropdowns, search input, time range select, sort controls
</script>
<!-- #endregion FilterBar -->
```
### 3.6 TaskList [C:4] [TYPE Component]
```svelte
<!-- frontend/src/lib/components/reports/TaskList.svelte (MODIFY existing — enhance with pagination, sort, active-first) -->
<!-- #region TaskList [C:4] [TYPE Component] [SEMANTICS component, list, task, pagination, sort, task-status-center] -->
<!-- @BRIEF Paginated task list with active-tasks-first sorting, status badges, and click-to-drawer. -->
<!-- @LAYER UI Component -->
<!-- @UX_STATE loading: skeleton rows -->
<!-- @UX_STATE empty: "No tasks" empty state with CTA buttons (inbox icon) -->
<!-- @UX_STATE filtered_empty: "No tasks matching filters" + clear filters button -->
<!-- @UX_STATE ready: task rows rendered -->
<!-- @UX_FEEDBACK Row click: opens TaskDrawer. Status change: animated status badge transition. -->
<!-- @UX_RECOVERY Empty: "Clear filters" button. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
<!-- @RELATION CALLS -> [TaskDrawer] -->
<!-- @PRE tasks is an array of TaskReport (may be empty). -->
<!-- @POST Clicking a task calls onSelectTask(taskId) which opens TaskDrawer. -->
<!-- #endregion TaskList -->
```
### 3.7 ReportsTypeProfiles [C:2] [TYPE Module]
```typescript
// frontend/src/lib/components/reports/reportTypeProfiles.ts (MODIFY existing — add clean_release)
// #region ReportsTypeProfiles [C:2] [TYPE Module] [SEMANTICS profile, task-type, visual, mapping, task-status-center]
// @BRIEF Visual profiles for canonical task types — synchronized with backend type_profiles.py.
// @RATIONALE R8: Frontend profiles must match backend TaskType enum. Added clean_release entry.
// @RELATION DEPENDS_ON -> [Backend.type_profiles] via @DATA_CONTRACT
export function getReportTypeProfile(taskType: string): TypeProfile {
const profiles: Record<string, TypeProfile> = {
llm_verification: { key: 'llm_verification', label: 'LLM Verification', variant: 'info', icon: 'sparkles', fallback: false },
backup: { key: 'backup', label: 'Backup', variant: 'default', icon: 'archive', fallback: false },
migration: { key: 'migration', label: 'Migration', variant: 'default', icon: 'shuffle', fallback: false },
documentation: { key: 'documentation', label: 'Documentation', variant: 'default', icon: 'file-text', fallback: false },
clean_release: { key: 'clean_release', label: 'Clean Release', variant: 'success', icon: 'shield-check', fallback: false }, // ← NEW
};
return profiles[taskType] ?? profiles.unknown;
}
// #endregion ReportsTypeProfiles
```
---
## 4. Contract Traceability Matrix
| Spec FR | Contract(s) | Type | Complexity |
|---------|-------------|------|:----------:|
| TSC-FR-001 | `SummaryPanel`, `Reports.SummaryRouter`, `Reports.SummaryService` | Component, API, Service | C4, C4, C4 |
| TSC-FR-002 | `TaskCenterModel._onWsMessage`, `SummaryPanel` | Model, Component | C4, C4 |
| TSC-FR-003 | `TaskList`, `Reports.ListEnhanced` | Component, API | C4, C4 |
| TSC-FR-004 | `FilterBar`, `TaskCenterModel.applyFilter` | Component, Model | C3, C4 |
| TSC-FR-005 | `TaskList` (row template), `TaskReport` | Component, Model | C4, C3 |
| TSC-FR-006 | `TaskCenterModel.selectTask`, `TaskDrawer` | Model, Component | C4, existing |
| TSC-FR-007 | `TaskList` (awaiting_input indicator) | Component | C4 |
| TSC-FR-008 | `TaskCenterModel` (reconnect logic), `TaskCenterReportsPage` | Model, Page | C4, C4 |
| TSC-FR-009 | `Reports.RbacTaskFilter`, `Reports.SummaryService` | Function, Service | C3, C4 |
| TSC-FR-010 | `TaskCenterModel.filteredTasks` ($derived) | Model | C4 |
#endregion ModuleContracts