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 usesINF-*,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
| Aggregate | Purpose |
|---|---|
programmes | Top-level initiative envelope (e.g. Constellation Platform Development). |
projects | Units of delivery inside a programme. Per-project custom statuses, fields, stages, gates. |
stages | Ordered phases of a project's stage-gate pipeline. Each stage may have a gate. |
gates | Criteria-bundle that must pass for a stage to be marked COMPLETED. |
tasks | Planned work with acceptance criteria. Auto-transition to DONE on PR-merge via webhook. |
issues | Unplanned work — bugs, incidents, helpdesk. Has severity, optional sla_policy, assigneeId, transition state machine. |
time_entries | Time-tracking entries on tasks. |
feedback | Lightweight quick-feedback intake; promotes into a task or issue. |
invitations | Out-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 (thept list-taskssubcommand calls this).POST /api/issues/:id/transition— move an issue betweenOPEN/IN_PROGRESS/CLOSED/REOPENED.POST /api/webhooks/github— auto-transition tasks toDONEwhen 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 fromcoordinator.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 theconsults_tenant_readRLS 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 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 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):
| Field | Type | Notes |
|---|---|---|
labels | string[] | Free-form tags; GIN-indexed for containment queries. Defaults to []. |
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). 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.
| 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 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_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)
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) |
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.
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 redacted — taskKey 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-tasksnow accepts?labels=<a,b>(comma-separated, OR semantics, ≤ 20 labels of ≤ 100 chars each — the sameparseLabelsParambounds as the per-project and goal-view routes). Labels are a content field, not a visibility-leak vector, so the filter is honoured in bothmode=allandmode=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-labelsreturns the distinct, sorted set of task labels for populating the filter chip (mirrors/api/issue-tagsfor the Issues Tag axis), scoped to exactly the tasks the caller can list via/api/my-tasks— distinct from the lighter, most-used-firstGET /api/tasks/labelsautocomplete 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 — fortasks.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
collaboratorIdsto/api/my-tasks(honoured inmode=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'scookie/Authorization/x-act-as-orgso the wiki scopes the lookup to the same acting org. A404(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
spaceIdslimit; 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-spacesis a thin PT-side proxy (gated oninitiatives.readorprojects.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(CHECKjsonb_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_isolationpolicy keys oncurrent_setting('app.tenant_id', true), matching theprojects.saved_filter_presetsshape.
API:
GET /api/dashboard-layout— returns the stored{ layoutVersion, widgetOrder }for the current(tenant_id, user_id), ornullwhen no layout has been saved. No server-side sanitization on read; the browser normalizes throughnormalizeStoredDashboardLayout(stored, visibleWidgetIds)(version guard → drop unknown ids → dedupe → drop hidden ids → append missing visible defaults inDEFAULT_DASHBOARD_WIDGET_ORDERorder). 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 than1with the standard400 VALIDATION_ERRORenvelope fromwithErrorHandler.- 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
requestSequenceonORG_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/criteriaPATCH /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.
| Endpoint | Method | Notes |
|---|---|---|
/api/issue-resolution-outcomes | GET / POST | List effective outcomes (built-in + custom); create a custom outcome. Write: projects.issues.manage. |
/api/issue-resolution-outcomes/:id | PATCH / DELETE | Edit / 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.
| Endpoint | Method | Notes |
|---|---|---|
/api/initiatives/:id/comments | GET / POST | List / create. Gated by verifyInitiativeAccess + initiatives.read (list) / comments.create (create). |
/api/initiatives/:id/comments/:commentId | PATCH / DELETE | Edit / soft-delete. Authors act on their own comments; others require comments.update / comments.delete.any. |
7. Layers + call direction
| Layer | Path | May import from |
|---|---|---|
| API Routes | src/app/api/ | Tools only |
| Tools | src/server/tools/ | Services, Policies, Events |
| Services | src/server/services/ | Repositories, Policies, @constellation-platform/db, @constellation-platform/audit |
| Repositories | src/server/repositories/ | @constellation-platform/db (Prisma for projects.*, raw SQL for identity.*) |
| Policies | access-control helpers | @constellation-platform/auth-core |
| Workflows | src/server/workflows/ | Tools or Services (never repositories) |
| Events | src/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
- Tool:
apps/project-tracker/src/server/tools/task.tools.ts— task CRUD, transitions, bulk ops. - Service:
apps/project-tracker/src/server/services/task.service.ts— task lifecycle, progress cascade triggers. - Identity reader (raw SQL):
apps/project-tracker/src/server/repositories/identity.repository.ts. - Events module:
apps/project-tracker/src/server/events/projects.events.ts. - Workflow:
apps/project-tracker/src/server/workflows/progress-cascade.workflow.ts— task completion cascades to stage / project / programme progress. - GitHub webhook:
apps/project-tracker/src/app/api/webhooks/github/route.ts— task auto-close regex\[([A-Z][A-Z0-9_]{0,9}-\d+)\]. - OpenAPI source:
apps/project-tracker/src/lib/openapi.ts.
9. Known exceptions / pitfalls
- Production basePath is
/projects. Any external integration URL must include the prefix — e.g. the GitHub webhook lives athttps://constellation.planetb2b.com/projects/api/webhooks/github. Local dev runs at/becauseNEXT_PUBLIC_BASE_PATHis unset. See Deployment. fetch()does not get basePath auto-prepended. UseapiUrl()fromapps/project-tracker/src/lib/api-url.tsfor any client-sidefetch().<Link>androuter.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 plainINF-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.*toidentity.*— they are in different schemas and the platform invariant is "schema-per-module, identity is read-only via repos". UseIdentityUserRepo/IdentityPermissionRepo. - Zod is pinned to v3.
import { z } from "zod"— do not usezod/v4. The OpenAPI generator depends on the v3 metadata format. SET LOCALdoes not survive PgBouncer. ThewithTenantContexthelper in@constellation-platform/dbusesSELECT set_config('app.tenant_id', $1, true)instead, and Prisma is pointed atDIRECT_URLfor transactions. Don't reach forSET LOCALin raw SQL.getCurrentUser()is expensive in tenant-wrapped handlers. PrefergetCachedUser() ?? await getCurrentUser().- The
ptCLI 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 tool | pt CLI subcommand | Purpose |
|---|---|---|
list_initiatives | pt list-initiatives | List all initiatives with project counts and progress. |
get_initiative_summary | pt get-initiative-summary | Get one initiative with full project list. Use at session start for orientation. Accepts UUID or name for initiativeId. |
create_initiative | pt create-initiative | Create a new initiative (always ACTIVE, no project links). Required: name, startDate. Optional: description, endDate. |
update_initiative | pt update-initiative | Update an initiative's name, description, status, dates, or knowledgeBaseSpaceIds (wiki KB space UUIDs, max 10). Accepts UUID or name. |
delete_initiative | pt delete-initiative | Delete an initiative. Cascades — removes the record and unlinks projects (projects are NOT deleted). |
Breaking change (PLT-165): the old MCP tools
list_programmesandget_programme_summaryand thept programmecommand are removed. Use the_initiativeequivalents. TheprogrammeIdparameter onget_programme_summaryis renamed toinitiativeId.
Project tools
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
list_projects | pt list-projects | List all projects (status, progress, task count). |
find_project | pt find-project | Resolve a hint (UUID, name, prefix, or repo path) to a project UUID. |
get_project_overview | pt get-project-overview | Full project details including stages and custom statuses. |
create_project | pt create-project | Create a project. Required: name, startDate. Optional: description, endDate, prefix, initiativeId (UUID or name). |
update_project | pt update-project | Update a project's settings. Optional: name, description, status, startDate, endDate, prefix, issuePrefix, initiativeId, knowledgeBaseSpaceIds (wiki KB space UUIDs, max 10). |
delete_project | pt delete-project | Delete a project. Irreversible and cascades — permanently deletes ALL tasks, issues, stages, comments, and time entries. |
Task tools
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
list_tasks | pt list-tasks | List tasks with filters (status, assignee, parent). |
list_epic_children | pt list-epic-children | List 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_task | pt get-task | Get full task details. |
create_task | pt create-task | Create 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_task | pt update-task | Update 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 tool | pt CLI subcommand | Purpose |
|---|---|---|
list_issues | pt list-issues | List issues (bugs, incidents, support tickets). |
get_issue | pt get-issue | Get full issue details. |
create_issue | pt create-issue | Create a bug/incident/support ticket. |
update_issue | pt update-issue | Update issue title, description, or due date. |
assign_issue | pt assign-issue | Assign an issue to a user. |
escalate_issue | pt escalate-issue | Change issue severity. |
transition_issue | pt transition-issue | Move issue between statuses. |
resolve_issue | pt resolve-issue | Resolve an issue with a reason. |
Other tools
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
get_workspace_context | pt workspace | Orientation: current user, dogfood initiative, top IN_PROGRESS tasks. |
consult_coordinator | pt consult | Ask the hosted coordinator brain an initiative-wide question. |
list_stages | pt list-stages | List stage-gate pipeline stages for a project. |
See also
- Tasks page — unified work surface at
/projects/tasks(replaces the old/my-taskspage). Scope toggle, multi-view, inline editing, bulk actions. - Implementation Plan — feature roadmap, completed phases, current scope.
- Project Tracker API — auto-generated reference.
- CLI overview —
ptCLI andconstellationMCP server, when to use which. - Deployment & Migration Plan — Vercel project, Supabase database, rollout strategy.
- Domain events index — full payload schemas for the events listed in §5.