Skip to main content

Project Tracker module

Programmes, projects, tasks, issues, stages, gates, time tracking, GitHub integration. Constellation dogfoods this module to track its own development — every Constellation engineering ticket lives in PT.

  • Source: apps/project-tracker/
  • Schema: projects
  • Project Tracker prefix: PT-* (this module's own work; the dogfood programme uses INF-*, PLT-*, DIR-*, CAT-*)
  • Hosting: sub-zone behind Directory; production basePath is /projects/* (rewritten from the root zone).
  • MCP server + CLI: tools/mcp-server/

1. Purpose

Project Tracker is Constellation's stage-gate project coordination engine. It owns programmes (top-level initiatives), projects (units of delivery), tasks (planned work) and issues (unplanned work — bugs, incidents, support tickets), with a configurable per-project stage pipeline guarded by gates and gate criteria. Two surfaces wrap the REST API for AI-agent integration: the pt CLI and the constellation MCP server. The module also subsumes the platform's "Quality / Helpdesk" responsibilities — issues with severity / SLA policies are first-class.

2. Component diagram (C4 L3)

Call direction is strict: API Route → Tool → Service → Repository. The pt CLI and MCP server hit the same REST routes — there is no second back door into the service layer. See route-wrapping.

3. Schema

projects — owned by Project Tracker. Reads identity.* via raw SQL (read-only) using IdentityUserRepo / IdentityPermissionRepo. RLS enforced on every tenant-scoped table.

4. Entities

AggregatePurpose
programmesTop-level initiative envelope (e.g. Constellation Platform Development).
projectsUnits of delivery inside a programme. Per-project custom statuses, fields, stages, gates.
stagesOrdered phases of a project's stage-gate pipeline. Each stage may have a gate.
gatesCriteria-bundle that must pass for a stage to be marked COMPLETED.
tasksPlanned work with acceptance criteria. Auto-transition to DONE on PR-merge via webhook.
issuesUnplanned work — bugs, incidents, helpdesk. Has severity, optional sla_policy, assigneeId, transition state machine.
time_entriesTime-tracking entries on tasks.
feedbackLightweight quick-feedback intake; promotes into a task or issue.
invitationsOut-of-tenant collaborator invites.

5. Domain events

Schemas live in @constellation/contracts and are re-exported by src/server/events/projects.events.ts. Indexed in the Domain events index.

projects.task.completed, projects.task.recurring_spawned, projects.gate.reviewed, projects.stage.progressed, projects.project.status_changed, projects.programme.progress_updated, projects.feedback.status_changed, projects.feedback.promoted, projects.project.exported, projects.project.imported.

6. Public API

Project Tracker is the only Constellation app with a published auto-generated reference. See Project Tracker API. Generated from apps/project-tracker/openapi.json via docusaurus-plugin-openapi-docs. To update: edit the Zod schemas in apps/project-tracker/src/lib/openapi.ts and run npx turbo run generate-openapi --filter=@constellation/project-tracker.

Illustrative routes:

  • POST /api/projects — create a project under a programme.
  • GET /api/projects/:id/tasks — list tasks (the pt list-tasks subcommand calls this).
  • POST /api/issues/:id/transition — move an issue between OPEN / IN_PROGRESS / CLOSED / REOPENED.
  • POST /api/webhooks/github — auto-transition tasks to DONE when their key appears in a merged PR title or body.
  • GET /api/coordinator/cost?initiativeId=<id>&period=month — month-to-date coordinator spend for an initiative (per-user and initiative-wide token + USD totals, aggregated from coordinator.consults). Backs the cost tiles in Project Tracker's coordinator workspace header (/projects/coordinator/<initiative>). Tenant + initiative isolation is enforced at both the query layer and the consults_tenant_read RLS policy.
  • GET /api/dashboard-layout / PUT /api/dashboard-layout — per-user, per-tenant home-dashboard widget ordering. See Dashboard layouts (PT-87) below.

Parent-task fields on GET /api/projects/:id/tasks (PT-266)

Tasks with subtasks (_count.subtasks > 0) carry two extra rollup fields on the paginated list 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 via getCompletedStatusSet so projects with no explicit isCompleted flags still report correct progress).
  • totalSubtasks: number — total number of children.
  • subtasks: { id: string; status: string }[] — each child's id paired with its current status, populated from the nested relation in hierarchical mode (parentTaskId filter absent or set to a specific UUID) or from a per-parent map in flat mode (parentTaskId=all). The board uses these to patch the parent's rollup column and progress chip 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.

Jira-compatible task fields + epic hierarchy (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):

FieldTypeNotes
labelsstring[]Free-form tags; GIN-indexed for containment queries. Defaults to [].
reporterIdstring | nullIdentity-user UUID. Validated to exist in the task's tenant on write (same check as assigneeId); no cross-schema FK.
resolutionstring | nullResolution outcome code. Built-in codes (FIXED, WONT_FIX, DUPLICATE, DEFERRED) plus per-org custom outcomes (PT-314).
resolvedAtstring | nullISO 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.
epicIdstring | nullTenant-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). It is a grouping pointer only — it does not feed the progress cascade. 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, and ?labels=<a,b> to filter by label (comma-separated, OR semantics — matches a task carrying any of them). 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.

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.

FieldTypeNotes
initiativeIdstring | nullInitiative 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 InitiativeDelegate, 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.

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_MODE default flip + Project.initiativeId retirement. With PT-490 the work-item path is correctness-complete (cascade + membership recalc wired and tested), so enabling work-items no 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 to work-items once backfilled-membership parity is observed in a real environment, and (2) the migration retiring Project.initiativeId + removing the legacy recalculateInitiativeProgress branch (behind a deprecation window). Until those land, the default stays legacy.

Tasks in the same project can be connected by typed lateral links — distinct from the hierarchy edges above. The supported link types are:

TypeSemantics
BLOCKSSource blocks progress on target
RELATESGeneral relationship
DUPLICATESSource is a duplicate of target
CLONESSource is a clone of target
CAUSESSource directly caused the target condition
FOLLOWSTarget task follows (is a follow-up of) the source (PT-479)

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 — status BACKLOG, or the project's default status if it has none, reporter = caller — and link it, in one action; PT-479). Permission: tasks.update.any for both intents; the create-new variant additionally requires tasks.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.

Cross-project label goal view (PT-448)

Labels are free-form and not project-scoped, so they double as a lightweight cross-project grouping key — the v1 "goal that spans projects" affordance (cross-project epics shipped with PT-446; the M:N cross-cutting initiative model shipped with PT-447 — see Cross-cutting initiative membership above).

GET /api/tasks?labels=<label> lists every task carrying any of the requested labels across every project the caller can read (tenant-scoped). It requires full tasks.read (the list is not ownership-scoped, so a tasks.read.own-only caller is rejected — mirrors the per-project full task list) and projects.read (or .own) plus an active membership; a GUEST member or a projects.read.own-only caller is restricted to collaborator/creator projects. The labels param is required and bounded (at most 20 labels, each ≤ 100 characters).

Access is enforced two ways: tasks in projects the caller cannot read are omitted at the query level (the readable-project set narrows the query, on top of RLS), and any cross-project dependency reference returned on a row is redactedtaskKey and projectId are omitted and title/status are replaced with the redactCrossProjectDeps sentinels ((cross-project dependency) / UNKNOWN), with isCrossProject: true, so only the opaque id survives unchanged and the real key/title never leak (PT-283 precedent). The blocked/blocking indicator is exposed as booleans (isBlocked / isBlocking) derived server-side from same-project TaskLink BLOCKS links and incomplete dependencies, so it never leaks a linked task's identity.

Each row carries { project, taskKey, title, status, priority, isBlocked, isBlocking, dependsOn, dependedOnBy } and is sorted by priority (URGENT first), task key as the tiebreak. Results are capped at the 500 highest-priority rows (the cap is applied after a priority ranking pass, so urgent work is never dropped in favour of lower-priority rows); the response envelope carries truncated: true when more match. This is a fail-safe for high-cardinality labels — full cursor pagination is deferred to the PT-206 board. The goal view is surfaced in PT V2 at /goals?label=<label> — bookmarkable and shareable, since a label is effectively a goal URL.

Label suggestions (PT-482)

GET /api/tasks/labels returns the distinct labels in use for the tenant, most-used first — the autocomplete vocabulary behind the Jira-style label combobox (type-ahead + create-new) on the task-detail editor, the task popup, and the create-task modal. The aggregation is unnest(labels) + COUNT(DISTINCT task_id) (a task is counted once even if its labels array repeats a value) ordered by count desc then label asc, run under the tenant RLS context.

Query params (all optional): projectId (UUID — narrows to one project; omitted = tenant-wide), q (case-insensitive prefix filter, ≤ 100 chars), and limit (clamped to [1, 200], default 50). The response is { data: { labels: [{ label, count }] } }.

Authorisation is deliberately lighter than the goal view: an active member who can read or create tasks — any scope satisfies it, including the .own variants (tasks.read.own / tasks.create.own). The response is non-sensitive autocomplete strings (never task content), so it doesn't need the goal view's full-tasks.read gate.

Task-list label filter + unified filter axes (PT-483)

Both Tasks-page surfaces — the org-wide /tasks page and the project-scoped /projects/:id/tasks page — render the same filter set, fed by /api/my-tasks (the project page pins its projectId). Two changes keep them from drifting:

  • Labels filter. GET /api/my-tasks now accepts ?labels=<a,b> (comma-separated, OR semantics, ≤ 20 labels of ≤ 100 chars each — the same parseLabelsParam bounds as the per-project and goal-view routes). Labels are a content field, not a visibility-leak vector, so the filter is honoured in both mode=all and mode=mine. It is task-only: the issue branch returns nothing when the filter is active, in both the Prisma and raw-SQL query paths.
  • Label options endpoint. GET /api/task-labels returns the distinct, sorted set of task labels for populating the filter chip (mirrors /api/issue-tags for the Issues Tag axis), scoped to exactly the tasks the caller can list via /api/my-tasks — distinct from the lighter, most-used-first GET /api/tasks/labels autocomplete vocabulary above (PT-482), which feeds the label editor tenant-wide. The filter-chip endpoint uses the collaborator-or-creator project window for GUEST / projects.read.own-only callers, and — for tasks.read.own-only callers — pins to their own assigned tasks, so the dropdown never exposes a label the caller's task list could not match.
  • Collaborators on both pages. The Collaborators filter (PT-471), previously only on the org-wide page, now also appears on the project page, which forwards collaboratorIds to /api/my-tasks (honoured in mode=all).

Multi-org requests — the x-act-as-org header (INF-143)

An API-key caller who is an ACTIVE member of more than one organisation can act across them with a single token: send the x-act-as-org: <organisation-or-tenant-uuid> header on any authed request to act as that org for that call. The value is validated server-side against live ACTIVE membership on every request (reusing the Directory enabler from DIR-67), so a revoked membership denies on the next call. Omitting the header — or sending the nil UUID — falls back to the token's default org exactly as before. A value naming an org the caller is not an active member of is rejected with a generic 403, never silently downgraded. The header is honoured only for API-key tokens; cookie/session web requests are unaffected. PT resolves it at its single acting-org resolution point (getCurrentUser()), so the matched tenant + org + RLS context applies uniformly across every tenant-scoped route.

Knowledge-base space references (PT-374)

Initiatives and projects each carry a knowledgeBaseSpaceIds: string[] field (DB column knowledge_base_space_ids uuid[] NOT NULL DEFAULT '{}') naming the wiki space(s) that serve as their knowledge base, so the coordinator brain and agents can scope wiki retrieval. It is read on GET /api/initiatives/:id and GET /api/projects/:id, and written on the matching PATCH (and via the update_initiative / update_project MCP tools / pt CLI). PT owns the reference; the wiki has no knowledge of initiatives or projects.

  • Validation (write time, fail-closed). PT does not read wiki.* SQL. On write it validates each supplied UUID by calling the wiki REST API server-side (GET …/wiki/api/spaces/:id), forwarding the caller's cookie / Authorization / x-act-as-org so the wiki scopes the lookup to the same acting org. A 404 (unknown or cross-tenant space) is rejected 400 listing the invalid IDs; an unreachable wiki or any other non-2xx (401 / 403 / 5xx) is rejected 503 — never silently accepted.
  • Cardinality cap. Capped at 10 entries, matching the wiki search spaceIds limit; an 11th id is a 400 at write time.
  • Empty-array fallback. An empty array means "no KB configured." A consumer doing retrieval treats stored-empty as "search the whole tenant wiki", but if a non-empty configuration filters down to zero usable spaces (deleted or temporarily unverifiable) it skips retrieval rather than widening — see the PT-374 spec for the full stored-empty vs filtered-empty contract.
  • Space picker proxy. GET /api/wiki-spaces is a thin PT-side proxy (gated on initiatives.read or projects.read, since it backs both the initiative edit form and the project settings panel) that lists the caller's wiki spaces ({ id, name, slug }[]) so the browser settings UI can populate a picker without reaching the wiki zone directly.

Dashboard layouts (PT-87)

The Project Tracker home dashboard (/projects/dashboard) lets users drag-reorder its three sections (my-tasks, summary-stats, projects-list). The order is persisted per-(tenant_id, user_id) in the new tenant-scoped projects.dashboard_layouts table:

  • Columns: id UUID, tenant_id UUID, user_id UUID, layout_version INT (CHECK = 1), widget_order JSONB (CHECK jsonb_typeof = 'array' AND no non-string elements), created_at, updated_at.
  • UNIQUE (tenant_id, user_id) — exactly one layout per user per tenant.
  • RLS is enabled and forced; the dashboard_layouts_tenant_isolation policy keys on current_setting('app.tenant_id', true), matching the projects.saved_filter_presets shape.

API:

  • GET /api/dashboard-layout — returns the stored { layoutVersion, widgetOrder } for the current (tenant_id, user_id), or null when no layout has been saved. No server-side sanitization on read; the browser normalizes through normalizeStoredDashboardLayout(stored, visibleWidgetIds) (version guard → drop unknown ids → dedupe → drop hidden ids → append missing visible defaults in DEFAULT_DASHBOARD_WIDGET_ORDER order). The render normalizer is the single sanitization point.
  • PUT /api/dashboard-layout — strict Zod-validated upsert. Body: { layoutVersion: 1, widgetOrder: string[] }. Rejects unknown widget ids, duplicates, and any version other than 1 with the standard 400 VALIDATION_ERROR envelope from withErrorHandler.
  • No project context. The home dashboard is org-wide; project-scoped dashboards are out of scope for PT-87. Adding / removing / resizing widgets and freeform 2-D placement are also out of scope.
  • Org switch. The hook bumps a local requestSequence on ORG_CHANGED_EVENT, so in-flight GET/PUT responses for the previous tenant are discarded rather than applied to the new active org.

Full design — hook state machine, save serialization (single in-flight PUT + 1-deep pending slot), and known limitations (no AbortController, multi-tab last-write-wins) — is in SPEC-pt-87-dashboard-layouts.

Gate criterion type RISKS_RESOLVED (PT-48)

A gate criterion can require that a specific list of project risks be closed (or formally accepted) before the stage progresses — the risk-register analogue of the existing DELIVERABLES_ACCEPTED criterion. The criterion type is RISKS_RESOLVED; the linked risks are persisted on the existing projects.gate_criteria row in a new linked_risk_ids uuid[] column (GIN-indexed, default '{}'), parallel to linked_deliverable_ids. Migration: 052_gate_criteria_linked_risks.sql.

Evaluation. isMet is true if and only if every linked risk is in status CLOSED or ACCEPTED. An empty linkedRiskIds is never isMet (no vacuous pass — enforced at the validator with min(1)). Cross-project or unknown risk ids are rejected at write time and treated as NOT_MET at evaluation time (defence in depth). Like DELIVERABLES_ACCEPTED, RISKS_RESOLVED is in the auto-evaluated set, so the gate auto-progresses without a human flip when its criteria pass; the manual-toggle is disabled for it in GatePanel.

API surface. All existing gate-criteria CRUD endpoints accept the new RISKS_RESOLVED value in type and the new linkedRiskIds: string[] field in create / update bodies. The GateCriterion response object also exposes linkedRiskIds. No new routes — the criterion type slots into the existing endpoints:

  • POST /api/projects/{id}/stages/{stageId}/gate/criteria
  • PATCH /api/projects/{id}/stages/{stageId}/gate/criteria/{criterionId}

Re-evaluation. Mirrors the issue-criterion pattern, not the deliverable-event pattern: a reevaluateRiskCriteria(projectId, prisma) helper runs synchronously inside the caller's transaction, computes the next-state via updateMany, and triggers auto-progression + progress recalculation internally. End-to-end wiring through transitionRiskTool / deleteRiskTool (so a risk transition flips its linked gate criteria in the same tx) ships in ST-2. ST-1 ships the schema, evaluator, validators, service writes, REST forward, OpenAPI surface, and unit + initial integration tests.

Import safety (defence-in-depth, ships in ST-1). Project import resets every RISKS_RESOLVED criterion to isMet=false with linkedRiskIds=[] and surfaces a per-criterion warning — same shape as the existing DELIVERABLES_ACCEPTED reset. Without this, a source criterion with isMet=true would round-trip into the destination project with linkedRiskIds=[] (DB default) while keeping isMet=true, violating the "empty list is never MET" invariant. The full export-side linkedRiskIds projection + UI surfacing of the warnings lands in ST-2.

Out of scope for ST-1 (deferred to later slices): the synchronous re-eval call sites in risk.tools.ts (ST-2), the GET /api/risks?fields=minimal&ids=… minimal-by-ids endpoint that backs gate-edit UIs without risks.read (ST-3), and the StagesSettingsSection / GatePanel UI work to actually configure and render the new criterion type (ST-4). Full design — SPEC-pt-48-risks-resolved-gate-criterion.

Customisable resolution outcomes + direct status actions (PT-314)

A single "complete" outcome is insufficient for helpdesk workflows. The four built-in resolution outcomes (FIXED, WONT_FIX, DUPLICATE, DEFERRED) stay as code constants; an organisation layers its own outcomes (e.g. SPAM) on top via the Issue Settings page. The effective set offered when resolving an issue is built-in defaults ∪ active custom outcomes — mirroring how the SLA service falls back to DEFAULT_SLA_TARGETS, so no per-tenant seeding is needed and new orgs work immediately. Custom outcomes live in projects.issue_resolution_outcomes (tenant-scoped, RLS, migration 057_issue_resolution_outcomes.sql); there is intentionally no FK from Issue.resolution, so a resolved issue keeps its outcome code even after an outcome is deleted. The resolve route validates the submitted code against the org's effective set (assertValidResolutionCode) instead of a fixed enum.

EndpointMethodNotes
/api/issue-resolution-outcomesGET / POSTList effective outcomes (built-in + custom); create a custom outcome. Write: projects.issues.manage.
/api/issue-resolution-outcomes/:idPATCH / DELETEEdit / remove a custom outcome. Built-in outcomes have no row and cannot be edited or deleted.

The issue detail also replaces dropdown/list status selection with direct one-click actions (IssueStatusActions): one button per valid transition from the current status (driven by the canonical VALID_TRANSITIONS map), with "Resolve" opening the outcome picker. Full design — SPEC-pt-314-issue-resolution-outcomes-status-actions.

Initiative comments (PT-525)

Initiatives carry comments end to end, alongside the existing task and issue comment surfaces. Rather than a dedicated table, comments reuse the projects.comments model via a nullable initiative_id FK (migration 059_initiative_comments.sql), so the existing tenant-isolation RLS, set_updated_at trigger, and soft-delete carry over unchanged. The initiative detail page renders a Comments section (composer + list + empty state) built from the shared comment components. Every mutation writes a best-effort post-mutation audit entry (resourceType: 'Comment'). Full design — SPEC-pt-525-initiative-comments.

EndpointMethodNotes
/api/initiatives/:id/commentsGET / POSTList / create. Gated by verifyInitiativeAccess + initiatives.read (list) / comments.create (create).
/api/initiatives/:id/comments/:commentIdPATCH / DELETEEdit / soft-delete. Authors act on their own comments; others require comments.update / comments.delete.any.

7. Layers + call direction

LayerPathMay import from
API Routessrc/app/api/Tools only
Toolssrc/server/tools/Services, Policies, Events
Servicessrc/server/services/Repositories, Policies, @constellation-platform/db, @constellation-platform/audit
Repositoriessrc/server/repositories/@constellation-platform/db (Prisma for projects.*, raw SQL for identity.*)
Policiesaccess-control helpers@constellation-platform/auth-core
Workflowssrc/server/workflows/Tools or Services (never repositories)
Eventssrc/server/events/@constellation-platform/events publish() + outbox

Enforced for catalog and directory by scripts/check-route-wrapping.ts (invoked via npm run check:routes); Project Tracker is not in that script's ROUTE_ROOTS today, so PT relies on code-review + the per-app authedRoute() helper to keep every route wrapped. Plus ESLint import boundaries across all three apps.

8. Code entry points

9. Known exceptions / pitfalls

  • Production basePath is /projects. Any external integration URL must include the prefix — e.g. the GitHub webhook lives at https://constellation.planetb2b.com/projects/api/webhooks/github. Local dev runs at / because NEXT_PUBLIC_BASE_PATH is unset. See Deployment.
  • fetch() does not get basePath auto-prepended. Use apiUrl() from apps/project-tracker/src/lib/api-url.ts for any client-side fetch(). <Link> and router.push() are auto-prefixed by Next.js.
  • PT bracket-link footgun. The GitHub webhook regex matches [KEY-123] anywhere in a PR title or body — including markdown link text like [INF-30](url). Releases that mention multiple ticket keys in their body will auto-close every one of them on merge. Use plain INF-30 (no brackets) when you only want to reference, not close.
  • Identity reads are raw SQL. Don't try to add Prisma relations from projects.* to identity.* — they are in different schemas and the platform invariant is "schema-per-module, identity is read-only via repos". Use IdentityUserRepo / IdentityPermissionRepo.
  • Zod is pinned to v3. import { z } from "zod" — do not use zod/v4. The OpenAPI generator depends on the v3 metadata format.
  • SET LOCAL does not survive PgBouncer. The withTenantContext helper in @constellation-platform/db uses SELECT set_config('app.tenant_id', $1, true) instead, and Prisma is pointed at DIRECT_URL for transactions. Don't reach for SET LOCAL in raw SQL.
  • getCurrentUser() is expensive in tenant-wrapped handlers. Prefer getCachedUser() ?? await getCurrentUser().
  • The pt CLI and MCP server hit the same REST API. They are NOT a second back door — auth, RLS, and audit apply equally. See CLI overview.

10. MCP / CLI tool reference

The constellation MCP server and pt CLI expose the following tools and subcommands for agents. Both surfaces call the same PT REST API.

Initiative tools (renamed from "programme" in PLT-165)

MCP toolpt CLI subcommandPurpose
list_initiativespt list-initiativesList all initiatives with project counts and progress.
get_initiative_summarypt get-initiative-summaryGet one initiative with full project list. Use at session start for orientation. Accepts UUID or name for initiativeId.
create_initiativept create-initiativeCreate a new initiative (always ACTIVE, no project links). Required: name, startDate. Optional: description, endDate.
update_initiativept update-initiativeUpdate an initiative's name, description, status, dates, or knowledgeBaseSpaceIds (wiki KB space UUIDs, max 10). Accepts UUID or name.
delete_initiativept delete-initiativeDelete an initiative. Cascades — removes the record and unlinks projects (projects are NOT deleted).

Breaking change (PLT-165): the old MCP tools list_programmes and get_programme_summary and the pt programme command are removed. Use the _initiative equivalents. The programmeId parameter on get_programme_summary is renamed to initiativeId.

Project tools

MCP toolpt CLI subcommandPurpose
list_projectspt list-projectsList all projects (status, progress, task count).
find_projectpt find-projectResolve a hint (UUID, name, prefix, or repo path) to a project UUID.
get_project_overviewpt get-project-overviewFull project details including stages and custom statuses.
create_projectpt create-projectCreate a project. Required: name, startDate. Optional: description, endDate, prefix, initiativeId (UUID or name).
update_projectpt update-projectUpdate a project's settings. Optional: name, description, status, startDate, endDate, prefix, issuePrefix, initiativeId, knowledgeBaseSpaceIds (wiki KB space UUIDs, max 10).
delete_projectpt delete-projectDelete a project. Irreversible and cascades — permanently deletes ALL tasks, issues, stages, comments, and time entries.

Task tools

MCP toolpt CLI subcommandPurpose
list_taskspt list-tasksList tasks with filters (status, assignee, parent).
list_epic_childrenpt list-epic-childrenList an epic's children across all projects in the tenant (PT-491). Children in projects you can't read are redacted (opaque id + isCrossProject: true).
get_taskpt get-taskGet full task details.
create_taskpt create-taskCreate a planned-work task. Optional epicId (group under an epic). Tag an EPIC to an initiative with MCP initiativeId (UUID only) or the CLI --initiative <id-or-name> (resolves a name or UUID) (INF-198).
update_taskpt update-taskUpdate a task (status, priority, assignee, etc.). Tag/detach an EPIC's initiative with MCP initiativeId (UUID only) or the CLI --initiative <id-or-name> (name or UUID); null / "" detaches — EPIC-typed tasks only (INF-198).

Issue tools

MCP toolpt CLI subcommandPurpose
list_issuespt list-issuesList issues (bugs, incidents, support tickets).
get_issuept get-issueGet full issue details.
create_issuept create-issueCreate a bug/incident/support ticket.
update_issuept update-issueUpdate issue title, description, or due date.
assign_issuept assign-issueAssign an issue to a user.
escalate_issuept escalate-issueChange issue severity.
transition_issuept transition-issueMove issue between statuses.
resolve_issuept resolve-issueResolve an issue with a reason.

Other tools

MCP toolpt CLI subcommandPurpose
get_workspace_contextpt workspaceOrientation: current user, dogfood initiative, top IN_PROGRESS tasks.
consult_coordinatorpt consultAsk the hosted coordinator brain an initiative-wide question.
list_stagespt list-stagesList stage-gate pipeline stages for a project.

See also