Work hierarchy & links
Project Tracker models work as a fixed four-level roadmap, mirroring Jira's Advanced Roadmaps stack so it can round-trip with external trackers:
Initiative → Epic → Story / Task → Sub-task
- Initiative — the top-level container (e.g. Constellation Platform Development). Supersedes the legacy "programme" term (PLT-165).
- Epic — a large body of work that groups base-level tasks. An epic may span projects (PT-446).
- Story / Task — a base-level unit of planned work with acceptance criteria (
issueTypeSTORYorTASK). - Sub-task — a child of a base-level task (
parentTaskId); terminal, one level deep.
Two distinct edges implement this, and it is worth keeping them apart:
| Edge | Field | Crosses projects? | Feeds progress rollup? | Feeds status rollup? |
|---|---|---|---|---|
| Epic → task | epicId | Yes — tenant-scoped (PT-446) | Yes (PT-606) | Yes (PT-900) |
| Task → subtask | parentTaskId | No — same project, one level | Yes | Yes (PT-900) |
| Initiative → epic | initiativeId (on the epic) | Yes — tenant-scoped (PT-447) | Behind a flag (below) | No — grouping only |
Cross-project structure and redaction
Because an epic can live in a different project from the tasks it groups — and an initiative's epics can live in several projects — a single initiative spans projects and a project can host epics from multiple initiatives (the many-to-many Initiative/Theme level). This is what makes an initiative a genuine cross-cutting roadmap entity rather than a per-project label.
Cross-project visibility is governed by a consistent envelope + member rule everywhere a cross-project list or reference is returned:
- Envelope check — you must be able to read the epic (or initiative) itself. A non-existent, cross-tenant, or unreadable target all return
403, so the API is never an existence oracle. - Member check — each child in a project you cannot read is masked (not dropped) down to an opaque
idplus the PT-283 sentinels:isCrossProject: true, withtaskKey/titlenever disclosed. Counts (totalCount) still include masked rows.
Only a holder of full org-wide tasks.read sees every child un-redacted. A same-project reference is returned in full; a readable cross-project reference carries projectId and isCrossProject: true; an unreadable one collapses to { id, title: '(cross-project epic)', isCrossProject: true } with taskKey omitted.
This masking pattern (envelope check + masked members) is used by the epic-children endpoint and the initiative work-items endpoint. The cross-project label goal view differs: it omits unreadable-project rows at the query layer (listGoalTasks builds the visible-project set first) and only redacts cross-project dependency references on the rows it returns — it does not return masked member rows.
Jira-compatible task fields (PT-375)
Tasks carry a set of Jira-aligned fields so the model can round-trip with external trackers (the Jira sync itself is tracked separately under PT-219):
| Field | Type | Notes |
|---|---|---|
labels | string[] | Free-form tags; GIN-indexed for containment queries. Defaults to []. See Labels & goal views. |
reporterId | string | null | Identity-user UUID. Validated to exist in the task's tenant on write (same check as assigneeId); no cross-schema FK. |
resolution | string | null | Resolution outcome code. Built-in codes (FIXED, WONT_FIX, DUPLICATE, DEFERRED) plus per-org custom outcomes (PT-314). |
resolvedAt | string | null | ISO timestamp mirroring resolution. |
issueType | 'TASK' | 'STORY' | 'BUG' | 'EPIC' | 'SUBTASK' | Discriminator, Zod- and DB-CHECK-enforced, default TASK. SUBTASK is auto-set when parentTaskId is non-null. |
epicId | string | null | Tenant-scoped epic membership (Jira's parent / Epic Link). The epic may live in a different project within the same tenant (PT-446). |
Hierarchy. PT mirrors Jira's fixed type stack with two distinct edges, not recursive subtasks: an EPIC groups base-level tasks via epicId; a base-level task owns sub-tasks via parentTaskId (terminal, one level — unchanged). The epic edge is tenant-scoped (cross-project supported) as of PT-446, matching Jira Advanced Roadmaps' universal parent field; the sub-task edge stays same-project. The roadmap level above Epic — cross-cutting initiatives — shipped with PT-447 (see Cross-cutting initiative membership below).
epicId is constrained to tenant-scoped, EPIC-typed targets by a single-column FK plus the enforce_epic_same_tenant_and_type DB trigger (the composite-FK SET NULL shape is unrepresentable in Prisma because project_id is NOT NULL; the trigger resolves tenant scope through projects.projects.tenant_id). As of PT-606 the epic edge feeds the progress cascade: an epic's progress is the leaf-equal mean of its non-cancelled leaf descendants across both the epicId and parentTaskId edges, so completing the tasks under an epic recomputes its percentage. On write, linking to an epic in a different project additionally requires the caller to be able to read the epic's project; a denial is indistinguishable from "epic not found", so the API never acts as an existence oracle. The service rejects epic-on-epic, epic-on-subtask, self-reference, converting a parent-with-subtasks into an EPIC, and converting an EPIC that still has children (in any project) to another type.
GET /api/projects/:id/tasks accepts ?epicId=<uuid> to list a single epic's children within that project, ?issueType=<TASK|STORY|BUG|EPIC|SUBTASK> to filter by type, ?labels=<a,b> to filter by label (comma-separated, OR semantics — matches a task carrying any of them), and ?cycleId=<uuid> to filter by cycle (sprint/iteration) membership (PT-698 — GET /api/my-tasks accepts the same param; task create/update accept cycleId and an estimatePoints story-point estimate, non-negative NUMERIC(6,2), ≤ 2 decimal places). GET /api/projects/:id/tasks/:taskId returns an epic reference with permission-based redaction for cross-project epics: same-project → { id, taskKey, title, isCrossProject: false }; cross-project and the caller can read the epic's project → { id, taskKey, title, projectId, isCrossProject: true }; cross-project and unreadable → { id, title: '(cross-project epic)', isCrossProject: true } with taskKey omitted (PT-283 sentinel pattern).
Because the project-scoped ?epicId= filter only returns children in that one project, GET /api/tasks/:epicId/children (PT-491) is its cross-project complement: it lists every task whose epicId points at the given EPIC across all projects in the tenant. Like the /api/tasks goal view it requires full tasks.read (the list is not ownership-scoped) plus projects.read (or .own) and an active membership. Access is two-layered, mirroring the work-items listing below: an envelope check — the caller must be able to read the EPIC itself (verifyUserProjectAccess), so a non-existent, cross-tenant, or unreadable-project epic all return 403 (no existence oracle) and a readable non-EPIC target returns 400 — and a member check, where each child in a project the caller cannot read is masked by redactMembersOutsideProjects to an opaque id + the PT-283 sentinels (isCrossProject: true, taskKey/title never disclosed). The response is { data: { epicId, items, totalCount } }; totalCount counts every child before redaction (masking, not dropping). Readable rows additionally carry assigneeId and estimatedHours (PT-500) plus a server-resolved completed boolean (PT-502) — completed is computed against the child's own project's completed-status set (getCompletedStatusSet: isCompleted flags, else the last status by sortOrder, else the legacy COMPLETED fallback — the same resolution used for same-project subtasks and cross-project prerequisites), so a cross-project child counts as complete even when the epic's project has no status of that name; all three readable-only fields are dropped on redacted rows along with every other identifying field. Only a full org-wide projects.read holder sees every child un-redacted. The task-detail "Child issues" panel consumes this endpoint (PT-500), so an epic's children from other projects appear there — a readable cross-project child links to its own project, an unreadable one renders as a redacted, non-interactive row. The panel's header completed / total count and aggregate bar use the per-row completed signal for epic children (PT-502) rather than name-matching the epic project's completed-status names; a redacted row carries no signal and counts conservatively as not-complete.
Parent-task rollup fields on the task list (PT-266)
Tasks with subtasks (_count.subtasks > 0) carry two extra rollup fields on the paginated GET /api/projects/:id/tasks response so the project-detail Kanban can render the parent card and decide its column without a refetch:
completedSubtasks: number— child count whose status sits in the project's completed-status set (resolved viagetCompletedStatusSetso projects with no explicitisCompletedflags still report correct progress).totalSubtasks: number— total number of children.subtasks: { id: string; status: string }[]— each child'sidpaired with its currentstatus, populated from the nested relation in hierarchical mode (parentTaskIdfilter absent or set to a specific UUID) or from a per-parent map in flat mode (parentTaskId=all). The board uses these to keep the parent's progress chip correct during optimistic subtask drags. Childless tasks omit the rollup fields entirely.
These fields are additive — existing consumers that ignore unknown JSON keys are unaffected. The OpenAPI 200-response schema for this endpoint is the project-wide { data: {} } opaque shape, so adding fields to data[*] does not require an openapi.ts change.
Derived container status (PT-900)
A container — any task with at least one child via epicId or parentTaskId; a childless EPIC is not one, and keeps a directly editable status — has its stored status derived from its direct children across both edges and written through by the same cascade that maintains derived progress. Because the value is stored rather than computed per view, every surface agrees: the task list, the Parents roadmap, task detail, the Kanban board, cycles, exports and the MCP.
The category is resolved against each child's own project's ProjectStatus.category (PT-326), so a cross-project epic child is classified correctly, and then mapped back to a status name in the container's project:
- any child
blocked→ blocked - every child
cancelled→ cancelled - otherwise cancelled children are excluded, and: every remaining child
done→ done - any remaining child
in_progress, or a mix of done and not-done → in progress - otherwise → not started
Two rules sit on top. A container that resolves to a not-started category while its derived progress is above zero is promoted to in-progress, so status and progress on one row can never disagree in direction. And when the container's project exposes no column of the resolved category, the stored status is left unchanged rather than set to a name the project's board has no column for — except where that promotion is what produced the category, which first falls back to a column whose NAME denotes it, so a project whose in-progress column was recategorised still gets the promotion.
Because the status is derived, editing it directly on a container that has children is rejected (400, "This task's status is derived from its children…") on every user-facing write path — PATCH on the task, the Kanban drag, bulk update, and the MCP update_task tool. A childless EPIC has no children to derive from and stays directly editable. Workflow automation that would set a status as a side effect does not author one either, but the two paths differ. Deliverable approval on a container is rejected (400, pointing at the children — the action belongs on the work that can actually be approved). The GitHub webhook suppresses the status write instead of failing: a PR opening records pr_link without the review transition, and a merge reports the task skipped and re-derives it, because the merge is already reflected through the child that completed.
The guard and the derivation are serialised on the container's row: the rejection is re-evaluated inside the write transaction with that row locked, and the cascade takes the same lock before it derives. So a task that gains its first child concurrently with a status edit resolves one of two ways — the edit is rejected, or it is accepted and immediately superseded by the derived value — and never leaves a container holding a status its children have already moved past.
Cross-cutting initiative membership (PT-447)
PT-447 lifts the epic edge one level: an EPIC-typed task may carry an initiativeId, making an initiative a roadmap entity that spans projects (Initiative → Epic → Story → Sub-task). Because an initiative's epics can live in different projects, a single initiative spans projects and a project can host epics from multiple initiatives — the many-to-many shape (Jira Advanced Roadmaps' Initiative/Theme level). Non-epic tasks inherit their initiative through their epic; a task with no epic falls back to legacy Project.initiativeId containment during the transition window.
| Field | Type | Notes |
|---|---|---|
initiativeId | string | null | Initiative membership. Only an EPIC-typed task may set it. Tenant-scoped; the initiative is a roadmap level above Epic (PT-447). |
initiativeId mirrors the epicId pattern exactly: a single-column FK (SET NULL on initiative delete) plus the enforce_initiative_epic_membership DB trigger and a tasks_initiative_only_on_epic_check CHECK constraint (which also catches demoting an EPIC that still carries an initiativeId). The service's validateInitiativeMembership rejects a non-epic carrier and a cross-tenant initiative, and — via the PT-446 verifyUserProjectAccess gate — an initiative that already spans a project the caller cannot read (surfaced as "Initiative not found", so the write path is never an existence oracle). It is a grouping pointer only; it does not feed the progress cascade and adds no new RLS policy (reads/writes inherit task-level RLS).
GET /api/projects/:id/tasks/:taskId returns a top-level initiative reference resolved in order — the task's own (epic) membership, then its epic's, then legacy project containment — with the same cross-project redaction as the epic ref: an initiative reachable only via an unreadable project is returned as { id, name: '(cross-project initiative)', isCrossProject: true }.
GET /api/initiatives/:id/work-items lists the work items tagged to an initiative (its epics + their base-level children) across projects. Access is two-layered: an envelope check (verifyInitiativeAccess — creator, active same-organisation InitiativeDelegate (delegate rows are tenant-bound per ADR-020 and grant nothing outside the initiative's own organisation), collaborator on a spanned project, or org member; both not-found and forbidden return 403) and a member check (redactMembersOutsideProjects masks each member in a project the caller cannot read down to an opaque id + the PT-283 sentinels, isCrossProject: true). Only a full org-wide projects.read holder sees every member un-redacted.
Tagging an epic to an initiative is done with the MCP create_task / update_task tools (initiativeId, UUID only) or the pt CLI --initiative <id-or-name> flag (which resolves a name or UUID); null / "" detaches. Only EPIC-typed tasks may carry it — the server rejects it on non-epics — and the link is single-valued, so re-tagging moves the epic rather than adding a second initiative.
Progress rollup (transition window). Initiative progress is computed by calculateInitiativeProgress, which selects its path from the INITIATIVE_ROLLUP_MODE flag. The default legacy path is avg(project.progress) — byte-identical to the pre-PT-447 behaviour, so the cutover changes no displayed progress. The work-items path (recalculateInitiativeProgressFromWorkItems) takes the unweighted mean of progress over the base-level work items tagged to the initiative via their epic (excluding cancelled), matching every sibling recalculate*Progress helper. The migration backfills initiativeId onto epics from their project's Project.initiativeId so the work-item rollup is verifiable before the flag flips. The PLT-182 dual-emit events (projects.programme.progress_updated + projects.initiative.progress_updated) are unchanged.
Cascade wiring + membership-change recalc (PT-490). The task progress cascade (create / update / delete / reorder) routes its initiative recalc through the single flag-aware entry point cascadeInitiativeProgress. Under legacy mode it recalculates the project's legacy initiative exactly as before; under work-items mode it resolves the changed task's initiative(s) via its epic (task.epicId → epic.initiativeId, its own initiativeId when it is a tagged epic, and its parent story's epic) and never writes the legacy project-avg into the shared Initiative.progress column. The stage- and gate-level cascades have no single changed task, so under work-items they deliberately no-op rather than clobbering the work-item value. Separately, when a PATCH/create/delete changes/clears an epic's initiativeId, recalculateInitiativeMembershipChange recalculates both the old and new initiative and emits the PLT-182 dual events for each (gated to work-items mode, so legacy stays byte-identical).
Deferred (gated) —
INITIATIVE_ROLLUP_MODEdefault flip +Project.initiativeIdretirement. With PT-490 the work-item path is correctness-complete (cascade + membership recalc wired and tested), so enablingwork-itemsno longer leaves tagged initiatives stale. Two follow-on steps remain intentionally out of PT-490 because they are irreversible-ish / high-blast-radius: (1) flipping the flag default towork-itemsonce backfilled-membership parity is observed in a real environment, and (2) the migration retiringProject.initiativeId+ removing the legacyrecalculateInitiativeProgressbranch (behind a deprecation window). Until those land, the default stayslegacy.
Typed lateral links (TaskLink, PT-479)
Tasks in the same project can be connected by typed lateral links — distinct from the hierarchy edges above. The supported link types are:
| Type | Semantics |
|---|---|
BLOCKS | Source blocks progress on target |
RELATES | General relationship |
DUPLICATES | Source is a duplicate of target |
CLONES | Source is a clone of target |
CAUSES | Source directly caused the target condition |
FOLLOWS | Target task follows (is a follow-up of) the source (PT-479) |
BLOCKS links are load-bearing beyond documentation: they drive the flow engine's readiness predicate and the isBlocked / isBlocking indicators on the goal view.
Both endpoints must be in the same project — PT-446 extended only the epic hierarchy edge across projects; cross-project lateral links remain unsupported and would use the same redactCrossProjectDeps gate as dependency refs if introduced.
Routes:
POST /api/projects/:id/tasks/:taskId/links— body is either{ targetTaskId, linkType }(link an existing task) or{ newTaskTitle, linkType }(create a new follow-up task — statusBACKLOG, or the project's default status if it has none, reporter = caller — and link it, in one action; PT-479). Permission:tasks.update.anyfor both intents; the create-new variant additionally requirestasks.create. Returns 409 on a duplicate(source, target, type)triple.DELETE /api/projects/:id/tasks/:taskId/links/:linkId— permission:tasks.update.any. Returns 404 if the link doesn't exist in this project/tenant.
GET task detail returns a links: { outgoing: [...], incoming: [...] } block. Each entry carries { id, linkType, otherTaskKey, otherTaskTitle } — same-project only (cross-project entries are filtered at the SQL layer).
The task_links table has composite FKs (source_task_id, project_id) and (target_task_id, project_id) both pointing at tasks(id, project_id) with ON DELETE CASCADE, so deleting a task removes all its lateral links automatically.
See also
- Project Tracker module — the module landing page.
- Labels & goal views — the cross-project label goal view and label filters.
- Flow engine — how
BLOCKSlinks gate the ready set. - Glossary: epic · initiative