Reference 0002 · Wiring map

What is real, what is fiction

The durable artefact of the merge decision. Verified against dbschema.md and the source tree, not against the module docs.

1 · The documentation lies

PM_MODULE_GUIDE.md §5 lists 14 pm_* tables. Only two exist. The rest is a design that was never built — or was built, abandoned, and never removed from the docs.

TableDocumentedActually in dbschema.md
pm_workspacesyesabsent
pm_portfoliosyesabsent
pm_programsyesabsent
pm_foldersyesabsent
pm_projectsyesexists as projects
pm_project_membersyesexists as project_members
pm_sprintsyesexists, unwired
pm_epicsyesabsent
pm_tasksyesexists as tasks
pm_task_assigneesyesexists as task_assignees
pm_checklistsyesabsent
pm_commentsyesabsent
pm_statusesyessubstituted by free text
pm_prioritiesyessubstituted by free text
pm_risksyesexists (RAID)

The 10-tier tree in PM_MODULE_GUIDE.md §2 — Workspace → Portfolio → Program → Folder → Project → Subproject → Epic → Sprint → Task — is not the shape of the data. Four of those tiers have no table. PMProject in the frontend even hardcodes workspace_id: "ws-1" and portfolio_id: "port-1" (pm-store.ts, project loader). Read the guide as product vision, never as schema.

2 · The real sprint graph

organizations ──┐
                ├─▶ projects ──┬──▶ project_milestones ──┐
                │             │                          │
                │             └──▶ tasks ◀────────────────┤
                │                    │  │
                │                    │  └──▶ task_assignees ▶ employees
                │                    ├──▶ timesheet_entries
                │                    ├──▶ performance.goals (linked_task_id)
                │                    ├──▶ employee_activity_buckets
                │                    └──▶ pm_risks
                │
                └──▶ pm_sprints ────────────────────────────┘
                        ▲
                        └── tasks.sprint_id  (FK, ON DELETE SET NULL)

Every arrow on the right of tasks is a live foreign key (dbschema.md lines 7053, 7466, 7984, 8593, 8628, 8698). Every arrow on the left — the sprint side — leads nowhere.

What the sprint side already has (schema is ready)

What it is missing (app layer)

3 · Zero-writer audit

The most damning table in this workspace. A column that no code path writes is a lie the database tells on every SELECT *.

ColumnDeclaredWriters foundConsequence
tasks.sprint_id uuid, FK zero Sprint membership can never be set. PMTaskFilters.sprintId in pm.service.ts is a filter no caller supplies.
tasks.story_points numeric DEFAULT 3 zero Loader hardcodes story_points: 3. Every unestimated task is 3 pts — the default destroys the only property story points have (ordinality).
tasks.task_type text DEFAULT 'Task' zero Loader hardcodes task_type: "Task". The Story/Bug/Epic taxonomy is display-only.
tasks.milestone_id uuid, FK many The milestone sibling of sprint_id is wired. Proof the pattern works — and that sprints were simply forgotten.
pm_sprints.* whole table zero The table is unreachable from the product.
action_required_id not a column none pm.service.ts:74 concedes it: "live tasks table has no action_required_id column (only dead pm_tasks)". The loader sets it = owner_id.

4 · Three bugs on the sprints screen

All in apps/frontend/app/pm/sprints/page.tsx, 156 lines total.

  1. It can never show a sprint. Line 28 seeds the selection from sprints[0]; sprints is []. The page renders its empty state permanently. Nothing any user does fixes this.
  2. The sprint filter is an OR. Line 34:
    tasks.filter((t) => t.sprint_id === currentSprint.id
                 || t.project_id === currentSprint.project_id)
    Every task in the project passes, regardless of sprint. If the bug above were fixed tomorrow, the "sprint board" would still show the whole project and the selected sprint would be cosmetic.
  3. committed_points is displayed, never computed. Lines 107 and 100 render completed_points and capacity_points straight from the row. Nothing anywhere sums tasks.story_points into them. They are typed constants.

5 · What is genuinely wired to HR

The module has one real interlock, and it is not the sprint one. capacity.service.ts reads, in order:

#TableRole in the capacity calculation
1employees + employee_capacitiesbase capacity hours / week
2project_resource_allocationsdedicated hours per project
3project_membersproject setup allocations (source: project_setup)
4leave_requestsApproved/Pending overlapping the week → subtract
5tasksestimated_hours where status_column != 'Done'
6timesheet_entrieslogged hours in the week → reality check
7holidays (via HolidayService)mandatory weekday holidays → subtract

Output is effective_capacity_hours and a workload_status of OVERALLOCATED / OPTIMAL / UNDERALLOCATED / BENCH. Note step 5: it filters neq("status_column", "Done") — a raw string comparison against one of the eight live spellings, precisely the bug apps/backend/lib/task-status.ts was written to fix and which this query has not yet adopted.

6 · Tenant scoping

RLS across this platform is uniformly permissive — pm_sprints_select and pm_sprints_manage are both USING (true), matching projects_all and 95 other policies. Isolation is done in the app layer by filtering organization_id on every query via the Admin client.

The one genuine sprint-side hazard: pm_sprints.organization_id is nullable while project_id is NOT NULL. A sprint whose org was not denormalised on insert is unfilterable by org-scoped query and therefore silently visible to every tenant through the anon client, since RLS permits it. The index idx_pm_sprints_org exists, so a WHERE organization_id = … will simply miss the row rather than throw.

Not an inconsistency — it is how this repo works. But it makes organization_id on pm_sprints a load-bearing column rather than a denormalisation nicety, which is an argument for adding a NOT NULL constraint and a backfill before the first writer lands.

7 · Fix order

  1. Fix the OR. One character in sprints/page.tsx:34. Until this is done, no sprint data is observable and every other fix is unverifiable.
  2. Write the two columns. Add sprint_id and story_points to the zod schemas in api/pm/tasks/route.ts and api/tasks/route.ts, and stop the loader hardcoding story_points: 3.
  3. Adopt classifyTaskStatus in capacity.service.ts:262 so the capacity query stops disagreeing with the scorecard about what is finished.
  4. Add the NOT NULL + backfill on pm_sprints.organization_id in a migration, before the first sprint is written.
  5. Delete the fiction. Rewrite PM_MODULE_GUIDE.md §2 and §5 to the real schema, or mark them "vision, not current state" in the first line.
  6. Only then decide whether capacity_points, committed_points and completed_points should exist at all. See Lesson 0001.

Terms used here are defined in Reference 0001 — Glossary.