Reference 0002 · Wiring map
The durable artefact of the merge decision. Verified against dbschema.md and the source tree, not against the module docs.
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.
| Table | Documented | Actually in dbschema.md |
|---|---|---|
pm_workspaces | yes | absent |
pm_portfolios | yes | absent |
pm_programs | yes | absent |
pm_folders | yes | absent |
pm_projects | yes | exists as projects |
pm_project_members | yes | exists as project_members |
pm_sprints | yes | exists, unwired |
pm_epics | yes | absent |
pm_tasks | yes | exists as tasks |
pm_task_assignees | yes | exists as task_assignees |
pm_checklists | yes | absent |
pm_comments | yes | absent |
pm_statuses | yes | substituted by free text |
pm_priorities | yes | substituted by free text |
pm_risks | yes | exists (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.
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.
tasks.sprint_id uuid → pm_sprints(id) ON DELETE SET NULL, plus index idx_tasks_sprint.pm_sprints.project_id uuid NOT NULL → projects(id) CASCADE.pm_sprints.milestone_id uuid → project_milestones(id) SET NULL — sprint terminating at a milestone.pm_sprints CHECK on status IN ('Planned','Active','Completed')./api/pm/sprints route. apps/backend/app/api/pm/ contains only projects, tasks, raid.createSprint / updateSprint in pm-store.ts — only a private setSprints and a read-only get sprints().pm_sprints anywhere in fetchData().INITIAL_SPRINTS = [] and readLocal does not require non-empty for this key — so the list is empty forever.The most damning table in this workspace. A column that no code path writes is
a lie the database tells on every SELECT *.
| Column | Declared | Writers found | Consequence |
|---|---|---|---|
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. |
All in apps/frontend/app/pm/sprints/page.tsx, 156 lines total.
sprints[0]; sprints is []. The page renders
its empty state permanently. Nothing any user does fixes this.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.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.The module has one real interlock, and it is not the sprint one.
capacity.service.ts reads, in order:
| # | Table | Role in the capacity calculation |
|---|---|---|
| 1 | employees + employee_capacities | base capacity hours / week |
| 2 | project_resource_allocations | dedicated hours per project |
| 3 | project_members | project setup allocations (source: project_setup) |
| 4 | leave_requests | Approved/Pending overlapping the week → subtract |
| 5 | tasks | estimated_hours where status_column != 'Done' |
| 6 | timesheet_entries | logged hours in the week → reality check |
| 7 | holidays (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.
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.
sprints/page.tsx:34.
Until this is done, no sprint data is observable and every other fix is
unverifiable.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.classifyTaskStatus in capacity.service.ts:262
so the capacity query stops disagreeing with the scorecard about what is
finished.pm_sprints.organization_id
in a migration, before the first sprint is written.PM_MODULE_GUIDE.md §2
and §5 to the real schema, or mark them "vision, not current state" in the first
line.capacity_points,
committed_points and completed_points should exist at
all. See Lesson 0001.Terms used here are defined in Reference 0001 — Glossary.