Advanced features & configuration
Reference for the smaller, self-contained Project Tracker capabilities that layer onto the core task / issue / stage-gate model. Each section is independent — read only the one you need.
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.
Promoting an issue to a task (PT-880)
An issue that turns out to be planned work is promoted to a task rather than re-typed: POST /api/issues/:issueId/promote either creates a new task in a target project or links an existing one, optionally closing the source issue.
There are two entry points, and between them they cover every issue surface:
| Where | Control | Surfaces it covers |
|---|---|---|
| Issue preview panel | Actions → Promote to Task | The /issues queue in both default views (Triage and Queue) |
| Issue detail header | Promote to Task, beside Escalate and Resolve | The full issue route, the Detail view, and the project-scoped issue queue |
Once an issue has been promoted the control collapses to a disabled Already promoted, and a chip links through to the task. An issue maps to at most one task — the link is a unique constraint, so a second promotion is rejected. When the viewer lacks access to the promoted task, no task key or title is disclosed.
Permissions. Promotion requires projects.issues.manage and projects.update on the source issue's project — both are enforced server-side, so the control is disabled unless the caller holds both at full scope (a .own-scoped grant does not qualify). The destination side additionally needs tasks.create to create a task, or tasks.update.any to link an existing one; the dialog probes these and only offers the modes you can actually use. Note that the link-mode task picker also needs full tasks.read to list candidates.
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. |
Both moderation permissions are seeded and grantable via the role editor: comments.delete.any since the original bootstrap, and comments.update via migration 063_seed_comments_update_permission.sql (PT-572), which grants it to every role holding comments.delete.any plus roles named Admin / Project Manager.
Comment rich text and markdown normalisation (PT-840)
Project Tracker stores comment bodies as HTML — that is what the UI editor produces and what the read path renders. API clients, however, overwhelmingly submit markdown, and before PT-840 that markdown was stored verbatim and rendered as literal text (## Heading, **bold** and - item on one flat line).
Every comment write path now normalises markdown → sanitised HTML at the boundary, so an API-authored comment renders exactly like a UI-authored one. This covers task, initiative and issue comments, the atomic submit endpoint's commentBody, the agent reporter, and the approval-rejection comment.
What API clients should expect:
- Send markdown or HTML — both work. A body with no markdown construct is stored unchanged, so plain text (including
2 < 3) is never mangled. - The stored body is HTML, so a
GETafter aPOSTdoes not echo your markdown back verbatim. Read back the returned body rather than assuming round-trip equality. @[Display Name](<uuid>)mention markup survives the transform and still resolves to a notification (and, for an assignable agent, still triggers a run). A mention whose display name contains<,>or"is deliberately left as plain text instead — it cannot come from the mention picker, and restoring it verbatim would reinsert unsanitised markup.- Inline images and fenced-code language classes are preserved (
,```mermaid), matching what the read path renders. - The 10,000-character cap applies to what you submit, before normalisation expands it.
Existing comments stored as markdown before this change are unaffected — there is no backfill; editing one in the UI repairs it. Full design — SPEC-pt-840-comment-markdown-normalise.
Read-path sanitisation is environment-split (PT-848)
The section above covers the write path. On the read path — every rendered task description, comment and initiative overview — Project Tracker sanitises again as defence in depth, and which engine does it depends on where the render happens:
| Render | Engine |
|---|---|
| Browser (hydrated, client navigation) | dompurify, against the real DOM |
| Server-side render (hard load, prerender) | sanitize-html, pure Node |
Both halves are selected by a #renderer-sanitizer subpath import and share one allow-list, and a
test suite pins their output against each other so the two engines cannot drift.
Why it is split rather than one engine. The browser-side library needs a DOM; the package that
supplies one for Node (isomorphic-dompurify) pulls in jsdom, whose tree contains CommonJS modules
that require() ESM-only ones. In a serverless function that throws ERR_REQUIRE_ESM at module
load and returns a 500 for the whole page. It caused three separate incidents (PLT-118, PT-339,
PT-848) before the split, the last of which 500'd task-detail and initiative pages in production.
What this means if you are working on the module: a 'use client' directive does not keep a
module off the server — Next.js server-renders client components, so their imports are evaluated in
the lambda. npm run check:ssr-sanitizer fails the build if isomorphic-dompurify re-enters
apps/project-tracker/src/** in any statically determinable form.
No behaviour change for API clients. Sanitisation output is identical to before the split; stored bodies, allowed tags, attributes and URI schemes are unchanged.
Dashboard layouts (PT-87)
The Project Tracker home dashboard (/projects/dashboard) lets users drag-reorder its registered sections: My tasks, Summary stats, Review queue (PT-603), Projects list, Current cycle (PT-677), Task velocity (PT-431), and Recent activity (PT-430). The order is persisted per-(tenant_id, user_id) in the 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.
Task velocity: story points and net flow (PT-971)
The Task velocity widget and the GET /api/tasks/velocity endpoint behind it report weekly delivery over a trailing window (12 weeks by default). Until PT-971 both reported a single figure — the number of tasks completed per week — which conflates a week of trivial tasks with a week of large ones, and says nothing about whether the backlog grew while that work was being closed. Each bucket now carries three things instead of one.
Story points delivered. points is the sum of estimatePoints over the week's completed tasks, rounded to two decimals. A task with no estimate contributes nothing to the sum but still counts toward the week's denominator, matching the convention computeSnapshotMetrics already uses for cycles; an estimate of 0 is a real estimate and contributes 0. Because estimate_points is NUMERIC(6,2) with no check constraint, a stored value that is NaN or negative is read as unestimated rather than summed — a NaN would poison the total and a negative would silently subtract from delivered work, and either would otherwise sit behind a confident-looking figure.
Estimate coverage, and suppression below the floor. Each bucket reports coverage: { estimated, total } and a verdict in pointsConfidence:
pointsConfidence | When | points |
|---|---|---|
sufficient | at least 80% of the week's completions estimated | the rounded sum |
low | below that floor | null — deliberately withheld |
empty | no completions that week | 0 |
The floor is published on the response as pointsCoverageFloorPercent, so a client explains a withheld week by the rule the server actually applied rather than by its own copy of the number, and is applied as the integer comparison estimated * 5 >= total * 4. The dashboard widget still carries a constant, used only when no response has arrived yet or when the published value is not a percentage — a fallback for the unmeasured case, not a second source of truth. A low week is withheld, not zeroed: 0 is itself a legitimate weekly total, so reporting it for a week that was merely unmeasured would be indistinguishable from a week that genuinely delivered nothing. This follows the same principle as the approximate cycle close, where velocity reads as unavailable rather than as a copied figure. No minimum bucket size is applied — coverage here is completeness over the whole week, not a sample, so a small week is a valid (if more volatile) measurement of its own completeness.
Net flow. Each bucket carries createdCount and netFlow (count - createdCount) — closed minus created, in tasks. A week that closed 70 while opening 50 advanced by 20; a negative net flow means the backlog grew. Completions and creations are folded from a single read of the same rows, because they are two readings of one snapshot: two separate queries would let a task created and completed between them be seen by exactly one of the two, and the reported net flow would then describe a week that never existed — an inflow with no matching delivery, or the reverse, depending on which query ran first.
Net flow is omitted for an own-only caller. When the request is scoped to the viewer's own assigned tasks, createdCount and netFlow are null and no created-task query is issued at all. Counting creations by project visibility would draw the inflow half from a wider population than the completions half; pinning it to the viewer's current assignee id would measure a retroactive cohort, so any reassignment would rewrite historical weeks. Task has no immutable creator to key on (reporterId defaults to the creator but is editable), so there is no honest personal inflow to report — and the widget says so rather than omitting the series silently, which would read as a net flow of zero.
Widget presentation. The widget offers a points/count toggle and names the series currently shown together with the window's estimate coverage. A suppressed week keeps its slot and shows an em dash, with the withheld weeks also named in text and in the widget's sr-only table — a gap in a bar chart is otherwise indistinguishable from a zero. Net flow is drawn as a line on its own right-hand axis against an explicit zero line, never sharing the bar series' axis: in points mode the two carry different units, and a shared axis would invite their relative heights to be read as a comparison. The chart uses recharts directly rather than the shared design-system BarChart primitive, which clamps negative values to zero and drops the caller's label when it does — so a net flow of −4 would render as a zero-height bar labelled 0.
Index on (project_id, created_at): 084_tasks_project_created_at_index.sql. Full design — including why suppression was chosen over a caveat, and the aggregation-in-SQL follow-up gated on a measured threshold — is in SPEC-pt-971-velocity-points-net-flow.
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 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}
Full design — SPEC-pt-48-risks-resolved-gate-criterion.
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.
Malformed ids in API paths (PT-1026, PT-705)
Every {id} and nested id in /api/projects/{id}/** (taskId, commentId, fieldId, statusId, stageId, criterionId, and the rest) is a UUID. A path segment that is not UUID-shaped never reaches the database. The route answers exactly what it answers for a well-formed id that names nothing:
- a malformed project id gets the generic
403that the family returns for a missing or inaccessible project, so an id's shape cannot be used to probe for projects; - a malformed nested id gets that route's existing answer for an absent sub-resource: usually a
404; the empty result on the taskcommentslist and oncheck-dependencies, which never reported a missing task; and the not-reviewable400onapprove.
Task keys are not accepted in the path. /api/projects/{id}/tasks/PT-446 is a malformed id, not a lookup. To go from a key to an id, call GET /api/resolve-key/{key} and use the UUID it returns.
As a safety net for any route without such a check, a non-UUID value that still reaches a uuid column answers 400 VALIDATION_ERROR ("Malformed identifier") instead of a 500. It is also reported to Sentry as a warning named after the query that took it, so the missing check can be found and added.
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.
Backfilling task keys for pre-PT-735 imports (PT-779)
Tasks written by an Excel import before PT-735 carry a NULL task_key /
task_number. Those rows render no <prefix>-N key, export a blank ID column,
and duplicate on the next export→import cycle — the @@unique([organisationId, taskKey]) constraint does not catch it, because Postgres treats every NULL
task_key as distinct.
apps/project-tracker/scripts/backfill-import-task-keys.ts repairs them. It is a
one-off, idempotent, dry-run-first operator script, not a shipped feature.
Connection. It is a cross-tenant admin migration, so it must run on a
privileged (BYPASSRLS) connection — the migration role behind DIRECT_URL. It
prefers DIRECT_URL and falls back to DATABASE_URL, and fails closed if the
connected role cannot bypass RLS: under RLS a non-privileged role reads zero
tenant rows, so the report would falsely say "nothing to do" and certify an
incomplete backfill.
Running it. Dry run first — a write pass is gated behind reviewing the Step-0 blast-radius report:
# Dry run, every org — blast-radius report + planned assignments, no writes
npx tsx apps/project-tracker/scripts/backfill-import-task-keys.ts
# Dry run, one tenant
npx tsx apps/project-tracker/scripts/backfill-import-task-keys.ts --org <tenantId>
# WRITE, one tenant — the recommended way, bounded blast radius
npx tsx apps/project-tracker/scripts/backfill-import-task-keys.ts --org <tenantId> --write
# WRITE, every affected tenant in one run — unbounded, explicit opt-in
npx tsx apps/project-tracker/scripts/backfill-import-task-keys.ts --write --all-orgs
--write without --org is refused unless --all-orgs is also passed, and
--all-orgs together with --org is refused outright — they state different
scopes. A tenant id that owns no project is refused rather than reported as
already-clean, so a mistyped id cannot pass as a no-op.
Quiesce first. The write pass locks every project row in the tenant, which blocks ordinary task-key and issue-key allocation there for the duration — but it is not a full quiescence gate. Run each tenant in a window with key-producing traffic and project creation paused: a project inserted after the locks are taken can still claim a planned prefix.
What it does not do. It keys rows; it does not merge duplicates already
created by a previous export→import cycle (those are flagged, never touched), and
it skips any task whose own organisationId is null or differs from its
project's tenant rather than keying it into the wrong tenant's bucket.
Audit. Each targeted tenant gets one auditCritical() entry inside the same
transaction as its writes, including when nothing changed. One run mints a single
correlation id — printed in the run record — so a run's per-tenant entries are
queryable as one operator action.
Recovery. Every write is idempotency-guarded and each tenant is its own transaction, so a failure leaves earlier tenants committed and the rest untouched: fix the cause and re-run. A re-run replays the applied tenants as no-ops. Planned changes reported as "not written" are either an idempotent replay or a row that drifted since planning — re-running tells you which.
Full design — SPEC-pt-779-backfill-import-task-keys.
See also
- Project Tracker module — the module landing page.
- Flow engine — the
review-queuedashboard widget referenced under Dashboard layouts. - Wiki spaces — the spaces referenced by
knowledgeBaseSpaceIds.