Skip to main content

Labels & goal views

Task labels are free-form tags (string[], GIN-indexed, default []) that are not project-scoped. That makes them Project Tracker's lightest cross-project grouping key: a label such as q3-launch used across several projects behaves like a "goal that spans projects" without any new entity. (The heavier, structured cross-project constructs are cross-project epics (PT-446) and cross-cutting initiatives (PT-447).)

Three surfaces read labels, each with a deliberately different authorisation bar.

Cross-project label goal view (PT-448)​

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-tasks accepts ?labels=<a,b> (comma-separated, OR semantics, ≤ 20 labels of ≤ 100 chars each — the same parseLabelsParam bounds as the per-project and goal-view routes). Labels are a content field, not a visibility-leak vector, so the filter is honoured in both mode=all and mode=mine. It is task-only: the issue branch returns nothing when the filter is active, in both the Prisma and raw-SQL query paths.
  • Label options endpoint. GET /api/task-labels returns the distinct, sorted set of task labels for populating the filter chip (mirrors /api/issue-tags for the Issues Tag axis), scoped to exactly the tasks the caller can list via /api/my-tasks — distinct from the lighter, most-used-first GET /api/tasks/labels autocomplete vocabulary above (PT-482), which feeds the label editor tenant-wide. The filter-chip endpoint uses the collaborator-or-creator project window for GUEST / projects.read.own-only callers, and — for tasks.read.own-only callers — pins to their own assigned tasks, so the dropdown never exposes a label the caller's task list could not match.
  • Collaborators on both pages. The Collaborators filter (PT-471), previously only on the org-wide page, now also appears on the project page, which forwards collaboratorIds to /api/my-tasks (honoured in mode=all).
  • Reporter filter (PT-1058). GET /api/my-tasks accepts ?reporterIds=<uuid,uuid> (OR semantics; malformed ids are dropped). It matches the current reporter, Task.reporterId on tasks and Issue.reportedBy on issues, so unlike the task-only axes it keeps issues. It is honoured in both modes, like labels, because it narrows rows the caller can already see. The reporter defaults to the user who filed the item but can be edited, so it answers "who filed this" only while nobody has changed it. A task with no reporter matches no selection. The chip is labelled Reporter, sits next to Assignee, and persists under the reporter URL key.

Defects: classify with a label, not the type (PT-1033)​

Mark a task that remediates a defect with a label, whether or not it has a parent, and build a defect queue on that label. issueType: BUG alone cannot do the job. (A defect report nobody is fixing yet is an issue, not a task; issues carry no labels, and have their own tags instead.)

The type fails because a task with a parent is a SUBTASK (the type/parent contract): parenting a BUG overwrites its type, and a request that asks for BUG on a parented task is refused rather than applied. A queue keyed on issueType=BUG therefore under-reports by construction — every defect filed under a container drops out of it. A label is orthogonal to the hierarchy, so it survives parenting and detaching alike.

Which label is a workspace convention, not a product rule. Constellation's own workspace uses kind:bug, the kind: axis of its backlog taxonomy (area: / kind: / triage:); the examples below use it.

Querying the queue. Parented defects are subtasks, and the per-project list endpoints return top-level tasks only unless told otherwise:

SurfaceIncludes subtasks
Goal view — /goals?label=kind:bug (GET /api/tasks?labels=)Yes — every project the caller can read, capped at 500 rows (truncated: true past it)
Tasks page label filter (/api/my-tasks?labels=)Yes, except in the Parents roadmap view, which lists containers only
Per-project list (GET /api/projects/:id/tasks?labels=)Only with parentTaskId=all
MCP list_tasks / pt list-tasksOnly with parentTaskId: "all" / --parent all

Defects filed before a workspace adopts the label may carry issueType=BUG and no label. Until they are labelled, the queue is the union of two separate queries — labels=kind:bug and issueType=BUG, both with parentTaskId=all on the per-project surfaces. Passing both filters in one request ANDs them, and the flow filters (ready / review / awaiting_decision) ignore them. The union still misses a defect that was parented before it was labelled: parenting made it a SUBTASK, so neither query finds it. Where a REPARENT audit row exists it records the type the task had before (see the type/parent contract); that row is the only place to recover it from.

Labels are a replace-the-set field on PATCH: read the current labels and send them back with the new one added, or the others are dropped.

See also​