Skip to main content

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.

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.

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:

WhereControlSurfaces it covers
Issue preview panelActions → Promote to TaskThe /issues queue in both default views (Triage and Queue)
Issue detail headerPromote to Task, beside Escalate and ResolveThe 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.

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.

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 GET after a POST does 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 (![alt](/api/.../raw), ```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:

RenderEngine
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 (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 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}

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'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.

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