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

22 KiB
Raw Blame History

#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]

# 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]

# 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]

# 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]

# 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]

// 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]

<!-- 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]

<!-- 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]

// 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]

<!-- 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]

<!-- 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]

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