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 tasks (PT-880, PT-881)​

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

One issue, several tasks (PT-881). An issue can be promoted more than once — a fix in one project and a documentation change in another, say. Once it has been promoted the control stays available and reads Promote to another task, including on a resolved or closed issue. A task still has at most one source issue: linking a task that is already linked to a different issue is refused, while linking it again to the same issue is accepted as a harmless retry. The preview shows a chip for the first promoted task with a +N count of the others, and the issue detail's Related tasks panel lists every one with its status and pull request.

Each promoted task is disclosed on its own: a task in a project the viewer cannot access is left out entirely, and the issue only says that it is also linked to tasks the viewer cannot see — never how many, nor their keys or titles. The issue read (GET /api/issues/:issueId and /api/issues/by-key/:issueKey) returns them as promotedTasks plus a hasInaccessiblePromotedTasks flag; the older single promotedTask / hasInaccessiblePromotedTask fields are still returned, deprecated, and describe only the first disclosed task.

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. Linking an existing one needs tasks.update.any and permission to list the project's tasks for the picker — full tasks.read, or gates.create / gates.update. A .own-scoped read does not qualify. The dialog probes both modes and only offers a project when you can complete that mode in it, so linking is not offered when you lack the permission to list the tasks to choose from.

Same organisation only (PT-886). The destination project, or the project of the task being linked, must belong to the source issue's organisation. A promote into another organisation is refused with 400 before anything is written, even when you can access both projects. This matches the cross-organisation refusal on issue move.

The issue queue's selection after a close (PT-883)​

When you close an issue from the queue's preview — Resolve, a status change, promote-with-close, snooze, or a bulk action — the queue refreshes and checks whether the issue still belongs in the view you are looking at:

  • It left the view (an Open view, say, or any view with a quick filter such as Unassigned, which never shows resolved or closed issues): the Triage and Queue views close the preview, and the Detail view closes its tab, moving to another open tab or to the empty state.
  • It still matches (an All or Closed view): it stays selected, even when it has moved past the first page of 50 — closing clears the SLA deadline, so the queue's urgency sort moves it behind every open issue. The Detail view keeps rendering its tab.
  • The check could not be made (a failed or superseded refresh): nothing is deselected.

The queue only deselects on positive proof. When the issue is not on the refreshed first page and that page is not the whole result, it asks the server with the same list query plus an exact issueId filter, so the answer applies every filter and access rule exactly as the list does. Both list endpoints, GET /api/issues/org and GET /api/issues, accept this optional issueId parameter; it only narrows the query. The generated API reference shows it on GET /api/issues only, because GET /api/issues/org is not in the reference yet (PT-1241). Changing the filters yourself does not close the preview.

Unlike the /tasks list (PT-1168), the issue queue does not keep a departed issue on screen as Hidden by filters. Full design — SPEC-pt-883-stale-selection-after-close.

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 its audit entry (resourceType: 'Comment') inside the mutation's own transaction, so a refused entry fails the request (PT-1065). 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.
  • A notification about a comment or description carries a plain-text preview of it: mention markup becomes @Display Name, and the text is cut to 200 UTF-16 code units at a whole character (PT-1273). Before PT-1273 a cut through an emoji was refused by the notification queue, and the notification was dropped. A numeric entity naming half a character (&#xD83D;) decodes to nothing, so a comment made only of such entities counts as empty; descriptions are unchanged.
  • @[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.

Comments stored as markdown before this change are not rewritten on read. Editing one in the UI repairs it, and an operator can repair them in bulk with the comment backfill (PT-860). Since PT-1245 the backfill runs per named tenant (ADR-069):

# from apps/project-tracker/, with DIRECT_URL set (there is no DATABASE_URL fallback)
npm run backfill:comments -- --dry-run --tenant <tenantId> [--tenant <tenantId> …] # report only
npm run backfill:comments -- --tenant <tenantId> [--tenant <tenantId> …] # apply
  • Tenants are always named. The script refuses a run that names no tenant. It reads and rewrites only the named tenants' comments.
  • Every run is audited, the dry run included. Each tenant gets a comment_markdown_backfill.started entry before it is read, then a page_applied entry for each page it rewrites, then an outcome entry. The entries record comment ids and counts, never comment text.
  • Exit codes. A tenant that fails makes the run exit 1. A refusal (an unknown argument, no tenant, no DIRECT_URL) exits 2.
  • Run one instance at a time.

Full design — SPEC-pt-840-comment-markdown-normalise and SPEC-pt-1245-operator-script-tenancy.

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.

Dashboard widgets (PT-88)​

Four widgets extend the home dashboard's registered sections. Each is gated by computeVisibleWidgetIds (src/lib/dashboard-visibility.ts), composing the same permission-matching rule its own data source enforces server-side — never the looser client hasPermission(), which (unlike the server) treats a .own grant as satisfying the base permission. One exception: initiative overview's GUEST exclusion is not something /api/initiatives enforces — that route admits a GUEST caller with a filtered list — it is the spec's own rule for this widget, carried over unchanged from the pre-PT-88 page's fetch gating:

  • Initiative overview (initiative-overview) — reads the page's own GET /api/initiatives list. Visible to any non-GUEST member with full (non-scoped) initiatives.read, matched exactly at the scope /api/initiatives's own permission check accepts — an action-level wildcard such as initiatives.read.* does not show the widget, even though a looser client-only match would (PT-1150).
  • Issue breakdown (issue-breakdown) — fetches GET /api/issues/stats?actionable=1 itself. Visible to the same callers loadIssueListAccess admits: full projects.read, or a strict projects.read.own match.
  • Gate progress (gate-progress) — offers a project picker over the page's own GET /api/projects list, then fetches the chosen project's GET /api/projects/{id}/stages?summary=1 itself. Visible to a caller the project-list gate admits AND who is either a collaborator-scoped reader (a GUEST, or hasOnlyScopedReadPermission's rule) or an org-wide reader (hasOrgWideProjectAccess's rule) — the same two paths verifyProjectAccess grants project access through, so every project the picker offers is one the widget can actually read the stages of.
  • Team activity (team-activity) — fetches GET /api/time-entries/team-activity itself. Visible to an INTERNAL member with org-wide projects.read (projects.read, projects.read.any, projects.* or *; not .own and not projects.read.*) — hasOrgWideProjectAccess's rule. The server counts only GLOBAL-scope role assignments for it (PT-1254); the client gate reads the scope-flattened permission list, so a member whose projects.read comes only from a role assigned on one project sees the widget's error state instead of it being hidden. The server also requires an ACTIVE membership; the client gate cannot see that flag, so it does not check it — a deactivated member sees the widget's error state instead of it being hidden (PT-725).

The page requests /api/initiatives and /api/projects only while the corresponding gate is open, and a closed gate discards whatever either fetch hook still holds — no data, no loading state, no error — rather than trusting a stale result. A failed request is reported by the widgets and summary-row stat cards that read it, never page-wide, so one list failing no longer blanks the whole dashboard for a caller who can still read the other. The whole widget grid — not only the two list-backed widgets — remounts on every organisation switch, in-tab or from another tab (PT-1150): the remount is keyed on the user id plus permissionsRequestedOrgId, the organisation the current permission set (and so the gates above) was requested for, so a previous organisation's rows or errors can never render under a new one. The saved widget order (useDashboardLayout) is a separate concern, above that remount boundary — it resets and reloads independently on the same organisation-id change, so a switch cannot leave one organisation's layout order rendering under another's, or dispatch a save still queued for the organisation it was left under (PT-1150).

Full design, including the three additive API changes (stages?summary=1, issues/stats?actionable=1, and time-entries/team-activity's unrounded loggedMs) and the full gate derivations, is in SPEC-pt-88-widget-library.

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:

pointsConfidenceWhenpoints
sufficientat least 80% of the week's completions estimatedthe rounded sum
lowbelow that floornull — deliberately withheld
emptyno completions that week0

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

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 403 that 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 task comments list and on check-dependencies, which never reported a missing task; and the not-reviewable 400 on approve.

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.

Structured 4xx rejections in Sentry (PT-1076)​

When a Project Tracker route rejects a request with a 4xx that carries details.reason in its error envelope, the rejection is reported to Sentry as a warning titled Classified <status> rejection: <reason>. A reason code names the rule that refused the request, so a run of one reason on one route can show that a feature has started refusing every request. Before this change, such rejections were recorded nowhere.

TagMeaning
routeThe route pattern. A segment holding a route parameter is replaced by the parameter's name, e.g. /api/invitations/[token]/accept; two parameters with the same value are named in path order, and a parameter whose value also appears as a route word is recorded as [param] at every occurrence, since nothing says which one is the parameter. Any other UUID still becomes [id], and any other segment that is not a lowercase route word (a task key, a number, an email) becomes [param]
methodThe HTTP method
statusThe response status, 400 to 499
errorCodeThe envelope's code, e.g. VALIDATION_ERROR
reasonThe details.reason code when it is one of the codes Project Tracker's rejections are known to carry (CYCLE_NOT_FOUND, PTD01, …); otherwise unregistered, so a value such as a name, token or UUID passed as a reason is never recorded. An unregistered rejection is still reported, keyed on its route
actingTenantIdThe tenant the request was authorised in; absent outside a tenant context. A route acting on a resource in another of the caller's organisations still reports the caller's acting tenant, not the resource's

Events are grouped by route and reason, so each pair is one Sentry issue. The route is recorded without the deployment's basePath, so production and preview group together.

The event is captured without the request's context: it carries only its message, level, fingerprint and the tags above, with no request URL, query string or headers, no transaction named after the concrete path, no trace or sampling context, and no user, breadcrumbs, extra data or attachments. It never carries the error message or any other details field. Its route tag carries no route parameter value either: if a handler is ever reached without its route parameters, every segment is recorded as [param] rather than guessed.

The report's flush waits at most 500 ms, the diagnostic reserve a route's transaction budget sets aside after database work, instead of the reporter's 2 s default. The response waits no longer than that; an event not yet delivered by then is not cancelled, and is sent later if the function keeps running or lost if it is frozen first.

The 409 for a task-status write that raced a status delete or rename (PT-939) carries a reason (PTD01 or PTD02), so it is reported the same way.

Rejections without a reason are not reported: Zod validation errors, malformed JSON, a not-found or validation error with no details.reason, a missing-permission 403, the 403 for a tenant that is not active, and Prisma constraint conflicts. 5xx handling is unchanged: an unexpected error is still captured as an exception and answered 500, and a 5xx AppError is still answered with its own status without being reported. Rejections a route builds and returns itself, such as its own 403/404 access response, are ordinary responses to withErrorHandler and are not reported.

Assignee names on activity-feed assignment rows (PT-1211)​

An assignment row in the activity feed (GET /api/activity, type task.assigned) reads its new assignee from the task's audit history. Before naming that assignee, the feed checks that they hold a relationship with the workspace, or with the project the row belongs to. A relationship is one of:

  • an active membership of the workspace;
  • being the project's creator, or an active collaborator on it;
  • being an agent account of the workspace.

The check runs once per page, at the moment the feed is read.

An assignee without a relationship still shows as an assignment, but unnamed: detail.assigneeId and detail.assigneeName are both null, and the row reads "assigned <task>" with no "to …" suffix. So a user who has since left the workspace appears unnamed on older assignment rows. Unassignment rows (task.unassigned) and actor names are unchanged.

Transaction failure diagnostics on search and activity (PT-1185)​

GET /api/search and GET /api/activity record a diagnostic when one of their database transactions fails with Prisma's transaction error (P2028 — an expired budget, a transaction that could not start in time, or one already closed). Nothing is recorded when a request succeeds.

The activity feed runs at most four of its source reads at once (PT-1204), so that one request cannot claim more connections than the pool holds. A read waits up to 2 s for a free slot. A read still waiting after that fails the request with an AdmissionTimeoutError, which is recorded with the same tags as a P2028. Its log line says admission timeout instead of P2028.

The Sentry event for such a failure carries these tags:

TagMeaning
txPhasesearch.ranked (search's ranked transaction) or activity.sources (the activity feed's source reads)
txSurfacedAtThe read the failure became visible at, or untracked when it happened before any read ran. In search's single transaction the reads queue on one connection, so this is where the failure was noticed, not necessarily the slow read
txWallMsMilliseconds from the start of the phase to the failure, including connection acquisition and, on activity, any wait for a free read slot
txLastSettledThe last read that completed before the failure, as <label>@<ms>, or none
txPendingHow many tracked reads had not finished, including, on activity, reads still waiting for a slot

A [tx-diagnostics] runtime log line carries the same record plus each completed read's offset. Neither records query text, tenant, user or row data. The 7.5 s transaction ceiling itself is unchanged; see PT_RUNTIME_TX_TIMEOUT_MS in the environment-variable registry.

How the task status and assignment reads stay bounded (PLT-1664, PT-1180)​

Two of the feed's source reads, task status changes and task assignment changes, come from the audit trail, newest first with a page limit. Under row-level security the planner cannot estimate the audit policy's predicates. Two separate changes keep these reads to about one page of work:

  • The first page. Platform-db migration 020 (PLT-1664) added one partial index per facet. That fixed the original incident, in which the first page read the organisation's whole task-audit history and timed out.
  • "Read more" deep in the history. A page older than a cursor could still sort every older matching change. Each of these reads now runs SET LOCAL enable_sort = off in its own transaction, which selects the ordered index scan (PT-1180).

Both depend on the migration-020 indexes. Without them the setting bounds nothing. For a feed scoped to one project with few or no such changes, the read walks the organisation's index entries for that facet. Feed rows, order and pagination are unchanged.

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.

Project access through organisation membership needs the organisation selected (PT-1232)​

A project is open to you when you created it, when you are an active collaborator on it, or when you are an INTERNAL member of its organisation with org-wide projects.read. The first two are relationships to the project itself and work from any organisation you are acting in. The third is an organisation-level grant, and under ADR-020 it applies only while you act in that organisation: a member of organisations A and B who is acting in A reaches B's projects by switching to B, not directly.

How you select the organisation depends on the client:

  • Web app — the organisation switcher (the x-active-org cookie). A link into another of your organisations shows a Switch to Org page instead of the project.
  • API key and MCP — the x-act-as-org header above; the MCP server sends each tool's organisationId as that header.
  • OAuth pt CLI — the organisation the token is scoped to.

A project-scoped request for another organisation's project, made without selecting it, gets the same generic 403 as a project you cannot reach at all — unless you created or collaborate on that project.

Initiatives follow the same rule (PT-1286): an initiative delegate row or membership of the initiative's organisation opens it only while you act in that organisation, whichever code path checks it. Creating the initiative, or collaborating on a project it spans, works from any organisation you are acting in.

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​