Estimate: 30m · Depends on: 6.2.1
The dashboard substrate. Pure backend — no UI.
Migration — dashboard (id, workspaceId, name, ownerId, access: 'private' | 'workspace', layout: 'one' | 'two' | 'three' columns, timestamps) and dashboard_widget (id, dashboardId, type, column, position — the shipped base-62 fractional index (lib/workItems/positioning.ts vocabulary) so drag-reorder is one-field, config Json, plus a NULLABLE savedFilterId FK (onDelete: SetNull — a deleted filter STALES the widget, never deletes it) and a nullable projectId FK (onDelete: Cascade)). Both FKs modelled as @relations on BOTH sides (the FK-drift rule). Indexes: [workspaceId], [dashboardId, column, position].
lib/dashboards/widgetRegistry.ts — the TOTAL per-widget-type registry (mistake #29): each type (filter_results, distribution, created_vs_resolved) maps to a config Zod schema (validating the data source — exactly one of savedFilterId/projectId — plus per-type settings: page size ≤50 / statistic-type id / period + days-back + cumulative), a data-source resolver, and the renderer/editor kinds (the UI contract for 6.3.3/6.3.5). Unknown type or malformed config → typed 422; the enumeration test fails on any registry gap.
dashboardsService (4-layer; one method = one transaction): dashboard CRUD (create = any workspace member; rename/relayout/access-change/delete = owner-only — typed 403 otherwise), widget add/update/remove/move (validated through the registry; the 20-widget cap → typed 422; move takes column + neighbour ids and computes the fractional index server-side, the board precedent), and the reads — listDashboards (private-to-me + workspace-shared, bounded) and getDashboard (widgets ordered by column/position; access enforced: private + not owner → 404-shaped denial). Routes are HTTP-only under app/api/dashboards/; the finding-#26 workspaceId gate covers every route. Fill the reserved 6.2.1 dependents line: extend the saved-filter delete-dependents enumeration with the widget FK join (the Cloud-style warning now counts "N dashboard widgets" alongside subscriptions — the line 6.2.2's warning design reserved); the delete itself stays SetNull (the widget goes STALE, the verified Cloud gadget behaviour — it never cascades).
@relations; deleting a saved filter nulls savedFilterId (stale, not gone); deleting a project cascades its project-sourced widgets; the 6.2.1 delete-dependents read counts the filter's widgets (asserted: the warning DTO names N widgets before a delete that stales them).pnpm test:coverage ≥90%.saved_filter table the FK references + the delete-dependents enumeration this extends — its "6.3 widgets join in by FK later" line)lib/workItems/positioning.ts + the 3.2 move-service pattern (fractional index, server-computed)motir-core/CLAUDE.md (4-layer, required-tx, FK/migration rules); finding #26 (workspace gate); lock-before-read-derived-update (the move tx)