Project Tracker module
Initiatives, 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 initiative 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 where the work gets delivered. Initiatives group related projects; each project moves through a configurable pipeline of stages separated by review gates, so progress is earned through sign-off rather than drag-and-drop.
Key capabilities
- Initiatives → projects → tasks (grouped under epics) with progress roll-up — see Work hierarchy & links
- Stage-gate pipelines: reviewers vote
PASS/FAIL/WAIVE/HOLD; passing auto-progresses the stage - Issues (bugs, incidents, support tickets) with severity levels and SLA-tracked queues
- A flow engine with a dependency-gated ready set and a review queue, plus in-product AI agents that execute work when assigned or @mentioned and report back in the task thread (allowlist-gated)
- Time tracking, GitHub integration, and notifications
Who uses it
- Project and programme managers running stage-gated delivery
- Delivery teams (agency and supplier side) working tasks and issues
- Programme offices watching cross-project progress roll up to initiatives
For product context, see How the pieces fit together. The rest of this page is the technical reference.
Project Tracker is Constellation's stage-gate project coordination engine. It owns initiatives (top-level container, see glossary — supersedes the legacy "programme" term, PLT-165), 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, tenancy & RLS
projects — owned by Project Tracker. Reads identity.* via raw SQL using IdentityUserRepo / IdentityPermissionRepo. PT's request-path code issues no direct writes to identity.*: where it needs to mutate identity (invitation acceptance, user provisioning, tenant bootstrap) it calls Directory-owned SECURITY DEFINER functions in the identity schema, which run inside PT's own transaction — see ADR-025. The CI gate npm run check:identity-writes enforces that over apps/project-tracker/src. Historical permission-seeding migrations under prisma/migrations/ and operator-run seed scripts still write identity directly; they are grandfathered, not endorsed, so new identity mutations belong in a Directory migration regardless of where they are triggered from. ADR-057 inventories the scripts' statements, and the same gate scans prisma/ and scripts/ to keep that inventory from growing. Where the sample seed (db:seed) and seed:stress write users, roles, role permissions, role grants or memberships, they do it through audited transactions, one per tenant, each ending with an auditCritical() entry (seed.identity_principals_seeded, seed.identity_authority_seeded) that records the rows actually written.
Tenancy is enforced in three layers, matching the platform invariant:
- Service-level
tenant_idscoping — still written explicitly, always. Services and repositories filter every query by the tenant (and, for reads, the caller's readable-project set). Never rely on RLS alone to scope a query: the explicit predicate is what makes the intent reviewable, and it is the layer that survives any future role change (the PT-564 / directory cross-tenant lesson, learned when RLS was inert). tenant_id+ RLS — an enforced backstop. Every tenant-scoped table also has RLS policies keyed on theapp.tenant_idPostgres session variable, which authentication attaches to the request (viawithTenantContext/set_config, notSET LOCAL— see §9). Since the INF-60 cutover (production has run on a non-BYPASSRLSrole since 2026-07-17) these policies are genuinely enforced, so a forgotten service-level filter is now caught by the database rather than silently leaking. One exception to keep in mind: Directory-ownedSECURITY DEFINERfunctions (see above) run as an RLS-exempt owner, so for their writes the in-function tenant assertion — not RLS — is the enforcement.- Cross-project redaction. Where a query legitimately spans projects, unreadable-project data never leaks — but via two different mechanisms. On the epic-children and initiative work-items listings, members in a project the caller cannot read are masked to an opaque
idplus the PT-283 sentinels (isCrossProject: true, key/title withheld) rather than dropped, so counts stay honest. On the label goal view, unreadable-project rows are instead omitted at the query layer, and only cross-project dependency references on the returned rows are redacted. See Work hierarchy & links § Cross-project structure and redaction.
Reads of identity.* (users, orgs, memberships) are the one cross-schema exception — via raw SQL, never a Prisma relation (§9). Identity writes are not a direct exception: they go through Directory-owned SECURITY DEFINER functions (ADR-025), so the mutation is authorised and versioned by the owning module even though it commits in PT's transaction.
4. Entities
| Aggregate | Purpose |
|---|---|
initiatives | Top-level container (e.g. Constellation Platform Development). Supersedes the legacy "programme" term (PLT-165). |
projects | Units of delivery inside an initiative. 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. |
platform_feedback_drafts | Agent-staged trial reports awaiting a person's review before they go to the platform team (PT-1267). |
invitations | Out-of-tenant collaborator invites. |
What counts as an "open" issue
An issue is open while its status is OPEN, IN_PROGRESS or REOPENED. RESOLVED and CLOSED are not. This single definition backs both the "N open" badge on a project's Issues card and the view the issue queue lands on when you open it, so the badge and the list beneath it always agree.
Two consequences worth knowing:
- In-progress issues are open. They are counted by the badge and shown by default in the queue. The queue's
OpenandIn progresspills are still separate filters — their counts are disjoint and sum to the badge — but both start active, so the default view is "everything still open" rather than "untouched only". Deselect either pill to narrow. - Snoozed issues are hidden everywhere. An issue snoozed until a future time is excluded from the queue and from the badge alike, and reappears in both once the snooze elapses.
Your last-used filter selection on the org-wide Issues page is remembered per user and tenant; Reset view returns you to the default above.
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.task.unblocked, projects.gate.reviewed, projects.stage.progressed, projects.project.status_changed, projects.initiative.progress_updated (plus the legacy projects.programme.progress_updated dual-emit, PLT-182), projects.feedback.status_changed, projects.feedback.promoted, projects.feedback.promoted_to_task, 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 an initiative.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, or when the PR branch names a task that the PR open put into review for this PR (PT-1142, see below).POST /api/webhooks/github/app— the GitHub App receiver (PT-1352): verifies the App signature, resolves the delivering installation's tenant, drives the installation and integration lifecycle, and applies inbound PRs for App integrations (see § GitHub integrations below).POST /api/github/connect-requests— start a GitHub App connect request (PT-1353), withprojects.github_integrations.manage. The browser then follows the App's Setup URL (GET /api/github/app/setup) and OAuth callback (GET /api/github/app/oauth/callback), which prove the connecting user's authority before any repository is mapped (see § GitHub integrations below).GET /api/github/integrations,PATCH /api/github/integrations/{id},POST /api/github/integrations/{id}/disableand/enable,POST /api/github/installations/{id}/refresh— the GitHub integrations settings (PT-1354), withprojects.github_integrations.managethrough a global role: list, project mapping and capabilities, disable and re-enable, and an installation refresh from GitHub (see § GitHub integrations below).GET /api/github/mappings/resolve?repositoryId=<id>|repository=<owner/name>&path=<path>— resolve a repository path to its project through the integration's path rules (PT-1486), for any member of the organisation (see § GitHub integrations below).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/coordinator/initiatives— the initiatives the caller may use the coordinator for (PLT-225): those they delegate, plus every UNCLASSIFIED initiative for an internal member holdingcoordinator.usethrough a GLOBAL-scope assignment. Backs the/projects/coordinatorpicker, the workspace's initiative switcher and its access check. It requires noinitiatives.read, because it names only initiatives whose coordinator APIs already admit the caller.GET /api/dashboard-layout/PUT /api/dashboard-layout— per-user, per-tenant home-dashboard widget ordering. See Advanced features & configuration § Dashboard layouts./api/coordinator/threads/**— the coordinator's persistent conversation threads (PT-1268). See Coordinator conversation threads below.
GET /api/auth/me tells an identity outage from "signed out" (PT-1444)
GET /api/auth/me answers a failed identity read with 503, as the shared createMeHandler does in the other zones:
| Status | Body | When |
|---|---|---|
200 | { "user": { … } } | The session resolves to an identity user. |
401 | { "error": "Not authenticated" } | No session, or a session whose identity read succeeded and found no identity row, for a bearer token and a browser session alike. |
403 | the platform error envelope | The x-act-as-org selector names an organisation the caller is not an ACTIVE member of. |
500 | the platform error envelope | The active-organisation membership read failed. |
503 | { "error": "IDENTITY_UNAVAILABLE" } | The identity read failed, for example on a database timeout. The failure is logged. |
The 401 and 503 bodies are flat strings, not the { error: { code, message } } envelope. The shared AuthProvider keeps its state and retries on the 503, and clears the signed-in user on a 401 during a token refresh. Before PT-1444 this route answered a failed identity read with 401, so a transient database error signed a Project Tracker user out of the UI until they reloaded. Every other Project Tracker route still treats a failed identity read as unauthenticated. The pt CLI and the MCP server report the 503 as an error, not as signed out, and no longer force a token refresh for it. Their message for it still blames the auth-provider configuration; INF-643 tracks rewording it as a transient outage.
Coordinator conversation threads (PT-1268)
Project Tracker keeps coordinator conversations as threads in @constellation-platform/coordinator's thread store: private to their author until shared, reopenable, and erasable. Project Tracker records each thread's initiative in projects.coordinator_thread_scopes, with a tenant-composite foreign key, forced row-level security and no update grant. Every route is in the generated Project Tracker API reference.
The coordinator workspace (/projects/coordinator/<initiative>, with each conversation at /projects/coordinator/<initiative>/threads/<thread-id>) and the task drawer show these threads. What users see — sharing, reopening, Retry, pins, deletion and the mode switch — is described in Persistent coordinator conversations.
The routes are thin adapters over @constellation-platform/coordinator's thread services. Each needs a signed-in caller (401) and an active organisation (400). The package decides authorship, participation and access, so a conversation the caller may not read answers 404 THREAD_UNAVAILABLE whether or not it exists.
| Route | Purpose |
|---|---|
GET /api/coordinator/threads/settings | The tenant's mode, { "data": { "mode": "off" | "read-only" | "on" } }. Answers in every mode. |
GET /api/coordinator/threads?scope&status&after&limit | The caller's conversations (scope: mine or shared; status: active or archived), each annotated with its initiative. |
GET /api/coordinator/threads?taskId&after&limit | The conversations linked to a task the caller can read. A missing and an unreadable task answer the same 404. |
POST /api/coordinator/threads | Create { initiativeId, title?, taskId? }, scoped to the initiative at its classification. With taskId the task is linked too, or nothing is kept. |
GET /api/coordinator/threads/search?q&after&limit | Search the titles and messages of readable conversations (q: 1–500 characters). |
GET / PATCH / DELETE /api/coordinator/threads/{threadId} | Read; rename (title) or archive (archived), exactly one per request; delete. |
GET /api/coordinator/threads/{threadId}/messages?beforeSeq&limit | One page of history, newest first (50 by default, 100 at most). |
GET / POST /api/coordinator/threads/{threadId}/participants | List the participants (author only); share with { userIds }, 1–20 people, all or nothing (422 PARTICIPANT_INELIGIBLE adds no one). |
DELETE /api/coordinator/threads/{threadId}/participants/{userId} | Remove a participant (author only). The list is read first, so a caller who cannot read the conversation changes nothing; the answer is that list without the removed person. |
GET / POST /api/coordinator/threads/{threadId}/pins, DELETE …/pins/{messageId} | List, pin ({ messageId }) and unpin. |
GET / POST /api/coordinator/threads/{threadId}/task-links, DELETE …/task-links/{taskId} | List, link ({ taskId }: 201 for a new link, 200 with created: false for an existing one) and unlink tasks. |
POST /api/coordinator/threads/{threadId}/turns | Send { requestId, question }, the question up to 64 KiB of UTF-8: server-sent events under Accept: text/event-stream, JSON otherwise. A replayed requestId starts nothing new, except that a turn accepted but never begun is begun by its replay. The consult's sources are read before anything is written. |
GET /api/coordinator/threads/{threadId}/turns/{turnId} | The turn's state, which the client polls after a dropped stream or a reload. |
POST /api/coordinator/threads/{threadId}/turns/{turnId}/retry | A new attempt of a failed or interrupted newest turn; the server reuses the stored question. |
GET /api/coordinator/threads/{threadId}/turns/{turnId}/actions | The turn's suggested actions, { items, stageable }. The thread author's own carry their receipt id and stage through POST /api/coordinator/pending-actions; everyone else's are read-only, and stageable says which. The workspace still shows a reloaded plan read-only until PLT-1995. |
A conversation stays readable when a task it used is deleted, for whoever can read the project the task was in and everything else the conversation used. Project Tracker records each hard-deleted task in projects.task_tombstones, with the project it was in, and answers the coordinator that the task was deleted with that project as its successor, so the conversation is re-checked against the project from then on. Deleting a project still makes the conversations that used its tasks unavailable, their author included (PT-1493).
The mode switch (COORDINATOR_THREADS_MODE, with the optional COORDINATOR_THREADS_TENANT_IDS cohort) gates them per tenant: in off, the default, every route but settings answers 404, and in read-only a write that adds content or widens access answers 503 COORDINATOR_THREAD_WRITES_DISABLED. A conversation with no Project Tracker initiative is read-only here: a turn, a retry, a rename, an archive, a share, a pin or a task link on it answers 409 COORDINATOR_THREAD_UNSCOPED, while delete, unshare, unpin and unlink stay available. A turn by a reader who cannot use the coordinator for the conversation's initiative answers 403 FORBIDDEN. Enrol a tenant with on only from a release that carries the thread workspace, A1 (#3091) and A2 (#3095), and the fixes that gated enrolment: PLT-1956 (#3098), PLT-1951 (#3194) and PLT-1950 (#3195). The release must also apply the thread-source wiring's seed migration 20261021141041_seed_coordinator_thread_tombstoned_subscription.sql before any tenant is enrolled in on, so a deleted conversation's scope row is removed when its tombstone event is delivered. It must also apply the task tombstone migration 20261023190931_task_tombstones.sql before any tenant is enrolled in on: a task deleted before that migration has no tombstone, so the conversations that used it stay unavailable (PT-1493).
Replies that look things up (PT-1268)
A reply in a conversation can look things up while it is written: the initiative's tasks (with their dependencies), its issues, a summary of its work, and past coordinator decisions on the same initiative while it is unclassified. It does so only for a sender who can list tasks across projects (full tasks.read and some projects.read); for anyone else the reply answers from the initiative snapshot, as before. A reply reads only the projects the conversation already draws on, and names a task outside them only when the sender can read it.
- What a reply looked up becomes part of what the conversation draws on. Every task, project and decision a lookup returned, and every task it names as a dependency or epic, is recorded with the conversation. Opening or sharing the conversation checks each of them again, so someone who cannot read one of them cannot be added, and a participant who loses access to one loses the conversation. If a task a reply looked up is later moved into a project the author cannot read, the conversation becomes unavailable to everyone, its author included. If it is deleted, the conversation stays readable for whoever can read the project the task was in, as for any other task the conversation used (PT-1493).
- "Used its whole lookup budget." A reply may make up to 24 lookups within 200,000 tokens and two minutes. Near the limit the coordinator stops looking things up and answers from what it found, saying what it could not check. A reply that reaches the token limit mid-lookup fails with "This reply used its whole lookup budget before it could answer. Retry, or ask a shorter, narrower question." A long question leaves room for fewer lookups.
- A looked-up reply arrives whole. Over server-sent events it sends nothing after the
turnframe until the reply is finished, then the whole answer at once. - Wiki pages are not read in conversations yet (PLT-1996): a request to read one answers "This result is not available in this conversation."
Role assignments scoped to one project (PT-1254)
Directory can assign a role at GLOBAL scope or on one project, programme or module. Project Tracker reads them as follows:
- Org-wide project access (every project of the organisation) comes only from
full
projects.read(projects.read,projects.read.any,projects.*or*) held through a GLOBAL-scope assignment, by an active INTERNAL member. - A PROJECT-scoped assignment carrying full
projects.readopens that one project to an active INTERNAL member, and no other. The project lists (GET /api/projects,GET /api/issues,GET /api/deliverables, mentionable users) show it next to the projects the member created or collaborates on. A PROJECT-scopedprojects.read.ownadds nothing; guests still need a collaborator relationship. - PROGRAMME- and MODULE-scoped assignments open no project.
- Inside a project, a permission counts only where its role was assigned
(PT-1280). A check about one project's resources (task, issue, stage, file,
collaborator and deliverable writes, internal-comment visibility, exports, the
project permission list behind
GET /api/projects/:id/permissions) evaluates the caller's GLOBAL-scope permissions plus the permissions of the roles assigned on that project. Sotasks.update.anyfrom a role assigned on project A does not authorise a task write on project B, even when the caller reaches B as a collaborator or its creator. The project-scoped half counts only for an active INTERNAL member; a guest's permissions come from their GLOBAL role, and their reach from being the project's creator or collaborator. On these checks, PROGRAMME- and MODULE-scoped assignments authorise nothing, where they used to count in every project the caller reached. Bulk move needs the permission in every source project and in the destination. - The same holds where the project is known late, or there are several
(PT-1348). These keep their earlier tenant-wide check as a necessary
condition, then require the permission in the project they act on:
feedback promotion and helpdesk issue create (in the target project; a
helpdesk project not created yet counts GLOBAL-scope roles only), the project
branch of
POST /api/organisations/:id/invitations, time-entry writes andGET /api/time-entries/by-task/:taskId(in the task's project), the issue-move target, sample-scenario removal (projects.deletein the sample project),GET /api/projects/:id/epic-options(in the project's own organisation) and the customer-context sidebar (in the issue's project). Multi-project operations require it in every project they touch: issue bulk actions fail just the issues whose project refuses, multi-project cycle create needstasks.createin each selected project, task eligible-cycles needstasks.update.anyin each project of the selection, and the promote destination picker offers only projects that grant the mode's permission. The time-entry lists leave out entries and tasks linked to a project in which the caller holds neitherprojects.readnorprojects.read.own. - Organisation-level permissions come only from GLOBAL-scope assignments
(PT-1347). Administering members, roles, invitations and organisation
settings; reading and changing initiatives (including their comments,
delegates, work items and cycles); the cycle lists' initiative, multi-project
and organisation arms, multi-project and organisation cycles; reports; project
creation and import, and installing the example scenario; feedback and platform feedback; the audit trail; and
managing issue categories, tags, queues, resolution outcomes and SLA policies
all evaluate the caller's GLOBAL-scope permissions only. So
org.roles.manageorinitiatives.createheld through a role assigned on one project, programme or module authorises nothing at organisation level. One exception is deliberate: the issue pickers' reads (GET /api/issue-categories,/api/issue-tags,/api/issue-queues,/api/issue-resolution-outcomes) still accept a project-scopedprojects.read, so a member working on one project's issues can load the values its forms need.GET /api/wiki-spaceslikewise keeps the flattened list: it backs the project settings panel, and the wiki authorises the spaces it returns.
Until PT-1254, any scoped assignment carrying projects.read opened every
project of the organisation. The cross-project task feeds (/api/my-tasks,
GET /api/tasks, the cycle list's project arm, risks, deliverables, flow
metrics), the coordinator consult scope, and the client's permission list
(GET /api/auth/permissions) still read the scope-flattened list. They are
tracked separately (PT-1009 and PT-1282).
The projects list (PT-1515)
/projects lists the projects you can read. It offers the same controls on a phone, a tablet and a
desktop; only their arrangement changes.
- Toolbar. A search field (name and description), a Filters button for status (a popover, a bottom sheet on a phone; it stays open while you pick, and shows how many statuses are on) and one View menu holding Group by status, Sort by with its direction, Columns and Reset view. New Project is the one primary action; Import sits in the More menu beside it (an Excel workbook or a JSON backup). While a filter is active the list says how many of how many projects it shows.
- Table or rows by the width the list has. Below 600 px of measured width — a phone, or a narrow window beside the sidebar — each project is a row: name and status, a two-line description, the progress bar, a line of dates, tasks and members, and the CSV export beside it. From 600 px it is a table whose header stays pinned and sorts on click (Project, Progress, Dates, Updated). Columns fold as the table narrows, Tasks first, then Updated, then Progress, and a folded value moves under the project's name; Members and Dates never fold on their own. While grouping is on, Status folds into the group headings. The width is the list's own, not the window's.
- What is kept where. The search text and status filter live in the URL (
?q=&status=A,B), so a shared link shows the same projects. The view — grouping, sort, direction and hidden columns — is remembered in the browser per organisation and user, and does not travel with a link. Older links withsearch=, a singlestatus=orsort=still work: the sort becomes your view's sort and the address is rewritten.
Project detail and attachment read access (PT-999)
GET /api/projects/:id and GET /api/projects/:id/files verify project access
before loading their payloads. A project creator, an active collaborator, or an
active INTERNAL member with full projects.read — from a GLOBAL-scope role, or
from a role assigned on that project (see above) — can pass that check. Guests and
INTERNAL members with only projects.read.own need a creator or collaborator
relationship; tenant-wide files.read alone does not grant project access.
Attachment listing also requires files.read in the project's tenant. Both
reads evaluate permissions and load data in that resource tenant, including when
the caller's active organisation differs. Missing, malformed and inaccessible
project IDs return the same 403 body: { "error": "Forbidden", "code": "FORBIDDEN" }.
Previously missing IDs returned 404. This guarantee covers status and body;
response timing is not equalized. A collaborator needs the relevant read grant
in the project's tenant; a grant only in their home tenant does not suffice.
Clients must treat this generic 403 as missing-or-inaccessible, even if older
MCP/CLI messages suggest a missing role. A file-list caller with project access
but no files.read receives the sibling routes' structured MISSING_PERMISSION
error, including details.permission; unauthenticated calls use the shared
structured UNAUTHORIZED error.
Authorized attachment requests retain the taskId and issueId filters and
default one-hour signed URLs. signUrls=false returns metadata with url: null;
it requires the same project access. Filter validation runs after authorization.
A signed URL opens PNG, JPEG, GIF, WebP and PDF files in the browser, and every
other file type, SVG included, downloads when opened (PLT-356).
Collaborator list read access (PT-1049)
GET /api/projects/:id/collaborators accepts four permissions, each evaluated in
the project's organisation and held in the project (GLOBAL-scope roles plus the
roles assigned on that project). A caller admitted only through projects.read
must also pass the project-access check the project detail read applies: be the
project's creator or an active collaborator, or be an active INTERNAL member,
acting in the project's organisation, with full projects.read from a
GLOBAL-scope role or from a role assigned on that project. So a guest holding
full projects.read needs a creator or collaborator relationship, and a member
acting in another organisation must switch to the project's organisation first.
The collaborator list is read only after that check passes, in the project's
tenant.
The management-reader permissions are unchanged: projects.update,
projects.collaborators.manage and org.invitations.manage admit the caller on
the permission alone, without the project-access check (PT-1433 tracks whether
they should require it). Only projects.collaborators.manage adds the
organisation's roles and the pending invitations to the response.
Missing, malformed and inaccessible project IDs, and a missing permission, all
return the same 403 body: { "error": "Forbidden" }. That is this route's own
body, without the code field the PT-999 reads above return. The guarantee
covers status and body; response timing is not equalized. A request refused
before it reaches the route — an organisation selection the caller cannot use,
or no valid tenant — gets the platform's nested error envelope instead.
Project files roll-up (PT-1391)
The project page's files card is a read-only roll-up of the files attached to the project's tasks and issues. Upload and delete live on the task and issue pages, which own the files. The card no longer offers a project-level upload: such an upload was stored as a composer draft, and the storage cleanup removed it after seven days.
GET /api/projects/:id/files now returns uploadedByName on every row. The
uploader's name is resolved inside the project's tenant only, so an uploader
whose account lives in another organisation comes back as null. Pass
rollup=true to get the roll-up:
- Unclaimed composer drafts are left out. An older project-level upload with no ticket stays listed, labelled "No ticket".
- A task file needs full
tasks.readin the project, and a file attached to an INTERNAL issue comment needsprojects.issues.manage. These are the activity feed's rules. - A file whose task or issue is no longer in the project is left out.
- Each row carries its source ticket:
task: { id, taskKey }orissue: { id, issueKey }.
rollup accepts only true or false, and any other value returns 400.
Without it you get the listing as it was.
Issue-comment attachments (PT-1441)
POST /api/issues/:issueId/comments accepts an attachmentIds entry only when
it names a file in the issue's project that is either already on the issue, or
a composer draft you uploaded (POST /api/projects/:id/files with no issueId
or taskId) that is not attached to anything yet. An entry that is not a UUID
fails the body's schema validation. Any other entry the rule refuses returns
400 with the same VALIDATION_ERROR body, whatever the reason, and nothing is
written. A file in another project or organisation, another issue's file and
another user's draft are all refused this way.
The issue page's composer (POST /api/issues/:issueId/submit) applies the same
rule, with the same 400, since PT-1443. Before, it also claimed an upload of
yours from before drafts had their own path, and refused a file already on the
issue. An issue moved to another project while either request is in flight is
refused with the generic 403, and nothing is claimed.
Posting a draft claims it for the issue. The draft is then listed under
GET /api/projects/:id/files?issueId=, and the storage cleanup no longer
removes it seven days after its upload. Drafts that earlier comments already
link were claimed when this shipped.
Editor attachments outside a project (PT-1456)
The rich-text editors of an initiative (its comments and its description), of a
cycle (its goal) and of the new-project, new-initiative and new-cycle forms
accept pasted, dropped and picked files. They are stored in the same
file_attachments table and storage as project files, with the same type
allow-list and 50 MB limit, and every one embeds one stable URL,
GET /api/files/:fileId/raw, which redirects to a freshly signed storage URL.
| Upload | Owner | Who may upload |
|---|---|---|
POST /api/initiatives/:id/files | the initiative | access to it, initiatives.read, one of files.upload / comments.create / initiatives.update, and the clearance |
POST /api/cycles/:cycleId/files | the cycle | the cycle's own scope arm's read access, and one of files.upload / tasks.update.any |
POST /api/files/pending | none until a create | an active member with one of files.upload / projects.create / initiatives.create / tasks.create |
The raw URL authorises on the owner, and answers 404 to everyone else:
- An initiative's file loads for a caller who passes the initiative's
access check and holds
initiatives.read, and whose clearance is at least the initiative'sclassification(PLT-212). A read of a SECRET initiative's file is audited and never cached. - A cycle's file loads for a caller its scope arm admits: project cycles
need the project and
tasks.readin it; initiative cycles the initiative's access, full-scopeinitiatives.readand its clearance; project-set and organisation cycles an internal member with organisation-wideprojects.read. An archived cycle's files do not load. - A create form's upload loads for its uploader only, until the create
claims it:
POST /api/projects,POST /api/initiativesand every cycle create acceptattachmentIds(at most 20) and claim them in the create's own transaction. An id that is not an unclaimed upload of the caller refuses the whole create with400. Once claimed, the same URL loads for whoever can see the new entity — for a new project, with the project file rule.
Pending uploads are rate-limited to 60 an hour and 20 unclaimed at a time. A create form gives back the uploads it will not claim — on cancel, on close, or once saved — through POST /api/files/pending/discard, which stamps the caller's own unclaimed uploads orphaned so they leave the quota at once. The
storage cleanup removes an upload no create claimed after 24 hours, and the
files of a deleted initiative or of an archived or deleted cycle 48 hours
after (ADR-070 Amendment PT-1456).
Attachment uploads that cannot be recorded (PT-1437)
POST /api/projects/:id/files and POST /api/feedback/attachments refuse a
file name the attachment record cannot store, one holding a NUL character, with
400 before anything is stored. Project file storage keys carry a random UUID
after the upload timestamp, so two uploads of the same name never share a key.
If the attachment record cannot be written after the file was stored, for
example because the task, issue or project it was attached to was deleted
during the upload, the stored file is queued for the storage cleanup instead of
being left behind. The route still answers with the insert's own error: 409
for a deleted parent.
Task and issue detail carry the owning tenant as a pinned contract (PLT-694)
Two detail reads publish their owning tenant as a guaranteed response field, not an incidental column:
| Route | Owning tenant at | Maps to |
|---|---|---|
GET /api/projects/:id/tasks/:taskId | data.project.organisationId | projects.tenant_id |
GET /api/issues/:issueId | data.organisationId | issues.tenant_id |
Both paths are named in @constellation/contracts (PT_TASK_DETAIL_TENANT_PATH, PT_ISSUE_DETAIL_TENANT_PATH) and declared as required on the routes' 200 schemas in the generated API reference. The schemas are deliberately partial: they guarantee identity plus the tenant and leave the rest of each detail body undocumented rather than closed.
A caller that must decide whether a task or issue belongs to its own tenant compares this field with its JWT-derived tenant. Passing the route's project-access check is not enough, because a cross-organisation collaborator can read a project in another organisation. The published schema promises presence and type: string only; PT's generator emits no pattern for response fields, so check the value is UUID-shaped before comparing it.
An issue's reporter is shown as the issue's tenant may see them (PLT-640)
Two surfaces name the person who reported an issue: the Triage Inbox customer card
(GET /api/issues/:issueId/customer-context, data.reporter and data.organisation)
and the company crumb on issue detail (GET /api/issues/:issueId, data.reporterOrgName).
Both resolve the reporter only under the issue's own tenant, and never read the
tenant the reporter is homed in.
| Reporter | reporter | organisation / reporterOrgName |
|---|---|---|
| Homed in the issue's tenant | full: name, display name, email, avatar, locale, time zone, PT role | their ACTIVE-membership organisation in that tenant |
| Homed in another tenant, related to the issue's tenant | display fields only: name, displayName, avatarUrl; email, preferredLocale, preferredTimeZone, ptRole are null | null |
| No relationship with the issue's tenant | name: "Unknown", every other field null | null |
reporter.email is therefore nullable, and a reporter that cannot be resolved is a
200 with "Unknown" and the tenant's own metrics — no longer a 404 Reporter not found.
A guest's membership organisation is not offered as their company: in the issue's tenant
it names the host. The display-only case goes through identity.resolve_principals, the
bounded constitution §1 exception of
ADR-039,
whose § Amendment (PLT-640) records this caller.
GET /api/people shows a member homed in another tenant by display fields only (PT-1197)
GET /api/people lists the members of the acting organisation, including cross-tenant
operators whose user account lives in another tenant (isCrossTenant: true, INF-322). For
those members it returns the same disclosure the reporter surfaces above return: name and
avatarUrl, with email and role set to null. role is the pt_role held in the
user's home-tenant profile. The fields this organisation owns are unchanged: orgRole,
memberType and the task and issue counts. So is isAgent, which the server works out
before it withholds the address. A member homed in the acting tenant is returned exactly as
before.
email is therefore nullable. The MCP find_user tool lists such a member by name, role
and id with no address, and a search by email cannot find them. Exposing that address again
would need a bounded constitution §1 exception that names the disclosure, not a code change.
GET /api/users is tenant-scoped in both modes, and 403s without an active org (INF-323)
This endpoint backs two things: the MCP / pt CLI assignee resolver — which
validates a UUID-shaped assigneeId against it before any write — and the
initiative delegate picker in the UI.
Both of its modes (the plain listing and the ?email= exact-match lookup) are
scoped to the caller's active organisation, and both fail closed with
403 + code: "NO_ACTIVE_ORG" when no active organisation can be resolved,
rather than falling back to an unscoped query.
Until INF-323 the listing branch queried identity.users with no tenant scope
at all — no tenant_id predicate and no app.tenant_id context. Under the
non-bypass RLS runtime role (PLT-273) that matched zero rows, so:
- every UUID-shaped
assigneeIdwas rejected with "No user with id … exists in your active tenant", for every caller, in their own organisation — whilefind_userhappily returned those same UUIDs; - the initiative delegate search silently returned no results for anyone.
Before the RLS cutover the same unscoped query returned every tenant's users, so the guard went from false-accept to false-reject without ever being correct.
Three properties of the listing are worth knowing, because the payload does not show them:
- Membership is not required for the tenant's own user rows. The base
listing is keyed on
identity.users.tenant_id, so it returns synthetic agent users (agent+<class>@constellation.local) whether or not they hold a membership row — and they vary: customer tenants carry none, while the dogfood / platform tenant keeps deliberate org-scoped agent memberships as a per-environment backfill (DIR-68 → DIR-69). That membership-independence is why the assignee resolver reads this endpoint rather than the membership-scoped/api/people, whosefindByOrgfilters onorganisation_idand would drop them. - Cross-tenant ACTIVE members are appended (INF-322). A user whose
identity row lives in another tenant but who holds an ACTIVE
user_tenant_membershipsrow in the acting one — a cross-tenant operator — is listed too, resolved through the SECURITY DEFINER user lookups — for callers holding full directory visibility (an ACTIVE INTERNAL membership plus an org-wide, GLOBAL-scopeprojects.read), so for them the listing is the assignee-verification set; previously such members were absent and their UUID was rejected as an assignee while they read and wrote the tenant throughout. A caller whose directory access is narrower — a guest, aprojects.read.own-only reader, or one whose read comes from a project/programme-scoped assignment — keeps the tenant's own user rows, mirroring the narrower/api/peoplewindow they already see, sofind_usercan offer them a UUID this endpoint does not list (PT-992). - It is not filtered by user status, so soft-deleted and suspended users
still appear — unlike the
?email=lookup, which filters them (appended cross-tenant rows require the ACTIVE membership itself). Tracked as PT-908.
GET /api/organisations returns only id and name (PLT-627)
This endpoint is the switch-target list — "which organisations can I switch
into?" — and it returns exactly { id, name } per organisation. It previously
returned the full organisation record; tenantId, type, status, metadata,
createdAt and updatedAt are no longer present, and have not been since the
2026-07-30 release. If you consume this endpoint outside the platform and read any
of those fields, read the full record from GET /api/organisations/{id} instead — it is
open to the same audience, since it gates on an ACTIVE membership in the organisation and
the list only ever returned organisations you are an ACTIVE member of.
The narrowing is intentional. All four Constellation zones (Directory, catalog, wiki, project-tracker) now answer this question through one shared implementation, so they cannot disagree about which organisations a user can reach — see ADR-023. Restoring project-tracker-only fields would re-create the drift that ADR exists to end.
Two behaviours of the list are worth knowing, because the payload does not show them:
- Rows span all tenants the caller holds an ACTIVE membership in, and the acting tenant's organisation is returned first. That ordering is load-bearing for clients that select a default.
- Organisations in tenants that are not
ACTIVEare omitted — including the acting tenant's own. PLT-476 exempted the acting tenant because the resolver still permitted acting inside a disabled one; PLT-477 moved the gate into both membership resolvers, so a non-ACTIVE tenant can no longer be entered at all and the exemption was removed. - The list is empty when the acting tenant contributes no visible organisation — even
if other tenants did produce rows. That is fail-closed on purpose: the switcher writes
organisations[0]intox-active-org, so a foreign tenant must never occupy the first position. Removing the liveness exemption above did not relax this.
The organisation switcher's "Create Organisation" entry (PLT-627)
Project Tracker is the only zone whose organisation switcher offers a create
entry, because its POST /api/organisations provisions a tenant, an organisation
and your ACTIVE membership together — so what it creates is immediately a switch
target. Catalog, Wiki and Directory show no create entry.
That is narrower than "creating an organisation is PT-only": Directory still creates
organisations through its own /organisations/new form. Those are tenant-local records
rather than new workspaces you join, which is exactly why that form is not wired to the
switcher. The full explanation lives with the zone that owns identity.* — see
Directory § Creating an organisation.
When the new organisation's knowledge base is not set up (PT-1582)
Creating an organisation also asks the wiki to give it its knowledge
base. The organisation is created whether or not that works, so the 201 says how
it went in data.knowledgeBase:
status | Meaning |
|---|---|
ok | The knowledge base exists. |
skipped | Setup was not attempted: no wiki is configured (wiki_not_configured), or the request ran out of time (no_time_left). |
failed | Setup was attempted and did not complete. reason says why: token_unavailable, request_failed, wiki_error, unreadable_answer, needs_admin or slug_conflict. |
reason is null for ok and otherwise one of these fixed codes; it never carries
anything the wiki or the deployment said.
When the status is not ok, the create dialog does not move you into the new
organisation at once. It says "Organisation created; knowledge base setup needs
attention", explains the reason, and offers Retry and Continue. Retry calls
POST /api/organisations/{id}/knowledge-base, which needs org.settings.manage in
that organisation (the creator has it) and re-runs the same setup. The setup is
idempotent: a knowledge base that already exists answers ok, and no second space is
created. Continue, or closing the dialog, takes you into the organisation; the MCP
tool provision_knowledge_base remains a way to retry later. needs_admin and
slug_conflict mean the wiki found a conflict an administrator must clear, which a
retry alone does not fix.
A deployment with no WIKI_ZONE_URL reports a configuration warning to error
reporting once per occurrence, under one grouping key, rather than an error.
Opening a task from another organisation (PT-1194)
A link to a task, issue, project cycle or project — a notification email, most often — can point into an organisation you belong to but are not currently working in. What you see depends on how you reach the project:
- You created the project or collaborate on it. The page opens, scoped to the project's organisation, while the organisation switcher keeps showing the one you were working in. When you also hold an active membership in the project's organisation, a banner says so: "This task belongs to Org, not the organisation you are working in", with a Switch to Org button. Clicking it makes that organisation active and keeps you on the page.
- You reach it only as a member of its organisation (PT-1232). Organisation
membership opens that organisation's projects only while you are working in it,
so the page shows nothing of the project — only "This link opens a project that
belongs to Org. Switch to Org to open it." ("This project belongs to Org…"
on the project's own page) and the Switch to Org button. Clicking it opens
the page in place.
/projects/tasks/<KEY>and/projects/issues/<KEY>links lead to the same page.
The page never switches organisation by itself: under ADR-020, switching is always explicit. A cross-organisation collaborator with no membership in the project's organisation sees no banner, because there is nothing to switch to.
Retrying a failed organisation creation resumes it (PLT-960)
POST /api/organisations is idempotent per slug for its creator. When a first
attempt bootstraps the tenant but fails before Directory finishes seeding its
default roles (the half-provisioned state), retrying the same slug as the same
user resumes that tenant — same id in the 201, roles re-seeded — instead of
burning the slug. The response of a resumed creation reports the persisted
organisation record, so a retry that carried a different display name returns the
stored name, not the request echo.
The 409 conflict now distinguishes two cases: a slug held by a tenant you are
not in returns the generic An organisation with this slug already exists,
while your own fully-provisioned tenant's slug returns a message saying you
are already a member of it — switch into it rather than re-creating. Two
overlapping create requests for the same slug are safe: seeding is serialized
per tenant on the Directory side, and the losing request either resumes or
receives the member-aware 409.
Organisation settings & roles access (PT-877)
The organisation settings page (/settings/organisation) gates its sections
independently rather than behind a single permission:
| Section | Required permission |
|---|---|
| Organisation profile, subscription & usage | org.settings.manage |
| Members | org.settings.manage to see it, org.members.manage for its data |
| Roles | org.roles.manage |
| Invitations | org.invitations.manage |
Page admission is the union of the three SECTION permissions —
org.settings.manage, org.roles.manage, org.invitations.manage. A principal
holding any one of those reaches the page and sees the sections it covers; a
principal holding none is refused. org.members.manage is not an admission
permission: it is only the data requirement for the members section, which
renders under org.settings.manage, so a caller holding just
org.settings.manage sees that section with an empty list because
GET /api/organisations/{id}/members rejects them. That split predates this
change and is unaltered by it. The Invite
people entry in the Create menu and the command palette is gated on
org.invitations.manage, matching the card it navigates to. The page does not
issue the organisation or members reads for a viewer without
org.settings.manage, and skips the roles read for anyone without
org.roles.manage — a roles-only caller does issue it. (The invitations
card issues one of its own to populate the invitation form — see the first
caveat below.)
Two caveats, both pre-existing behaviour rather than part of this gating:
- The invitations card fetches before its own gate.
InvitationManagerevaluates itsPermissionGateonly after issuing its reads and rendering a loading card, so the page mounts it solely fororg.invitations.manageholders rather than relying on that internal gate alone. - Access assumes the active-org cookie resolves to the selected tenant. That
holds for every seeded tenant, where an organisation's id and tenant id
coincide. Where an organisation's id and tenant id diverge,
/api/auth/permissionscan resolve a fallback tenant, so holding a permission in the selected organisation does not by itself guarantee the page admits the principal. See the residuals inSPEC-pt-877.
GET /api/organisations/{id}/roles requires an active membership in the
organisation, and resolves both the permission check and the role read against
that membership's tenant — not the path organisation id, which the Directory
schema does not guarantee to be equal. Callers with org.roles.manage receive
full role objects; callers with only org.invitations.manage receive the
minimal {id, name} projection used by the invitation form's role selector.
Denials use the standard { error: { code, message } } envelope: FORBIDDEN
when there is no active membership, MISSING_PERMISSION when neither permission
is held.
Invitation creation gating (PT-888)
POST /api/organisations/{id}/invitations carries two flows behind one URL and
gates them separately. Which flow runs is decided by the request body: a
projectId selects the project-scoped branch, its absence the org-level one.
| Branch | Required permission | Additional check |
|---|---|---|
Project-scoped (projectId present) | projects.collaborators.manage or org.invitations.manage | Caller must be able to reach the project |
Org-level (no projectId) | org.invitations.manage | — |
A project-scoped invitation grants access to one project as a guest
collaborator — the same act POST /api/projects/{id}/collaborators performs for
someone who already has an account, which is gated on
projects.collaborators.manage. Of the seeded default roles only Admin holds
org.invitations.manage, so gating both branches on it left Project Manager —
the role that actually runs a project — able to re-role and remove collaborators
but unable to invite anyone who has no account yet. The org-level branch keeps
requiring org.invitations.manage alone: it adds a member to the
organisation with an org role, which is a genuinely org-admin act.
Enforcement order is membership → union pre-gate → body parse → the branch's own requirement where it is narrower. The pre-gate accepts either permission and runs before Zod, so a caller holding neither cannot use a malformed body to probe the request schema. Only the org-level branch is re-checked — the union pre-gate already is the project branch's requirement.
A caller who fails the pre-gate holds neither permission and receives
403 { error: "Forbidden: requires one of 'projects.collaborators.manage' or 'org.invitations.manage'" },
phrased as alternatives because the endpoint cannot yet know which branch was
intended. A caller who clears the pre-gate but lacks org.invitations.manage on
the org-level branch gets the narrower
403 { error: "Forbidden: missing permission 'org.invitations.manage'" }.
The project branch also verifies project access, which the endpoint
previously did not — an org.invitations.manage holder could otherwise place a
guest collaborator into any project in the tenant, including projects they cannot
open. Every refusal on that axis is the same uniform
403 { error: "Forbidden", code: "FORBIDDEN" }: an inaccessible project, a
non-existent one, and a project belonging to another tenant are
indistinguishable, so the endpoint is not a project-existence oracle.
It admits any active INTERNAL member holding full projects.read, and
Project Manager is granted the whole projects resource — so for that role the
check passes for every project in the organisation. The effective capability
is therefore "any Project Manager can invite an arbitrary external email address
onto any project in the organisation", creating an identity and a
Guest Customer organisation membership on acceptance. That is the same reach
projects.collaborators.manage already carries on
POST /api/projects/{id}/collaborators. The two do not gate identically: that
sibling authorizes through its own helper and does not call verifyProjectAccess,
so this branch is stricter by exactly one condition — a custom role holding
projects.collaborators.manage without org-wide projects.read can add an existing
collaborator there and is refused here. That is moot for the seeded roles
(Project Manager selects the whole projects resource) and it narrows nothing:
before this change the branch required org.invitations.manage alone and made no
project check at all. What the check does bound is a caller who is not an
internal member with project read — a guest or scoped-INTERNAL account holding
org.invitations.manage — who can no longer place a collaborator into a project
they cannot open.
An outstanding redeemable invitation for the same email and project refuses
the grant paths with 409, instead of granting a second time or failing on a
unique-constraint violation. That guard is scoped to redeemable rows — PENDING
and not yet expired — because invitation expiry is lazy: a row can sit at
PENDING long past its expiry, and such a token cannot be redeemed, so blocking
the grant on it would refuse a legitimate one forever.
An invitation that can no longer be redeemed does not block a new one
(PT-1283). The invitations table is unique on email and project and keeps every
row, so the creation path re-issues the row the pair already holds instead of
inserting a second one: a new link, a fresh 7-day expiry, this request's roles and
inviter, status PENDING, and a new creation date. That covers an invitation that
was revoked, one that expired (whether or not anyone opened the dead link), one
still PENDING past its expiry, and one that was accepted by someone who has
since lost access to the organisation — anyone still an active member takes the
existing-member path above and never reaches this one. Only a redeemable
invitation answers 409, as does a concurrent request that created one first;
neither surfaces a raw unique-constraint error. Creating or re-issuing an
invitation is audited as invitation.created, naming the status a re-issued row
held before.
The org-level branch is unchanged: its duplicate check still refuses any
PENDING invitation for the email, expired or not.
Both branches email the invitation once, directly, whether or not the invitee already has an account in the organisation (PT-1301). They used to queue a second copy for an invitee who had one. An invitation is transactional — the email carries the only link that redeems it — so it is sent regardless of the recipient's notification preferences, and Settings no longer offers an "Invitation Received" switch.
Accepting an invitation never changes the project role of someone who is already an active collaborator. Previously, acceptance always re-applied the invitation's role, so a live token coexisting with a direct grant could silently revert the role that had just been assigned; the active-collaborator guard on the acceptance write now prevents that overwrite. The collaborator table has several independent writers — the collaborators endpoint, the role-update endpoint, this endpoint's direct grant, and invitation acceptance — so the protection lives on the acceptance write itself rather than on any one caller, and holds however the two came to coexist. When it applies, the audit entry records the role as unchanged rather than naming one that was not applied.
Note this covers the project role specifically. Redeeming a still-valid invitation continues to re-assert organisation membership and the organisation role that the invitation carries.
When the invited email already belongs to an ACTIVE member of the organisation,
the branch skips the invitation and writes the collaborator row directly. That
write is an upsert: an already-ACTIVE collaborator returns 409, while a
retained non-ACTIVE row — typically a PENDING one left by an invitation
that was never accepted — is reactivated with the new project role. Removal
(DELETE /api/projects/{id}/collaborators) deletes the row rather than
deactivating it, so re-inviting a removed collaborator inserts a fresh row and
never reaches the update path. The removal also deletes the collaborator's watches
on the project's tasks, a watch being added at that moment included: it either
waits for the removal and is refused, or completes first and is deleted with the
rest (PT-1255). A collaborator who still holds another relationship with the
project — a member of its organisation, its creator, one of its agents — can watch
again afterwards, and a watch that waited for the removal is kept on those grounds.
The grant emits one collaborator.added audit
entry in the same transaction as the write — see the
Universal audit log.
Because (userId, projectId) is unique globally rather than per tenant, a
conflicting row can sit outside the caller's organisation. That case gets its own
409 — "A conflicting collaborator record for this user and project could not be
written" — rather than being folded into the already-a-collaborator message,
which would claim a row exists in a tenant where it does not. The message
deliberately does not say the row belongs to another organisation: that would
confirm cross-tenant state to a caller who cannot otherwise observe it. The
specific cause is written to the server log instead.
The same answer is given by every writer of project_collaborators that surfaces
this conflict to a caller — the existing-member shortcut, the placeholder written
beside a new invitation, invitation acceptance, and
POST /api/projects/{id}/collaborators. (The auto-enrol inserts on the
feedback-promote, support-ticket and issue-create paths use
ON CONFLICT … DO NOTHING, so they never surface it at all — though on an
out-of-tenant conflict they also quietly write no row.) Acceptance is
the one that matters most: its caller is whoever holds the invitation token, who
may hold no membership in either organisation. The collaborators endpoint reached
the same case differently — its duplicate check is tenant-scoped, so an
out-of-tenant row was invisible to it and the insert failed on the global unique
constraint as a 500 — and now returns the same 409.
What this does not close: a 409 is still distinguishable from the successful
response a caller gets when no row exists at all — 201 on the two create paths,
200 on the invitation-acceptance routes — so a caller can infer that some row
exists for that user and project outside their organisation. Closing that needs the
database to make the state impossible — a collaborator row's tenant constrained to
its project's tenant — which is tracked as a follow-up rather than done here.
When the invitee has an account but is not yet an organisation member, the
invitation is created with a PENDING collaborator row alongside it. That write
is guarded the same way, for a reason worth stating: a collaborator row outlives
the organisation membership that justified it, so someone granted access directly
and later removed from the organisation still has a row. Re-inviting them used to
collide with the unique constraint and answer 500. It now reuses the row — and
if the row is already ACTIVE it is left untouched rather than demoted to
PENDING, since an invitation must never revoke access the person already has.
In that last case the requested project role matters. An untouched ACTIVE row
keeps its own role, and acceptance preserves it, so an invitation asking for a
different project role would be accepted and then never applied. That is
refused with a 409 pointing at the collaborators endpoint for role changes.
Inviting the person with the role they already hold still succeeds — nothing is
contradicted, and the invitation is what restores the organisation membership.
Project collaborators: one "Add people" entry point, display-only label (PT-889)
The Collaborators card on project settings has one Add people button,
shown to callers holding projects.collaborators.manage. It opens a dialog with
two modes:
- From the organisation adds an existing member through
POST /api/projects/{id}/collaborators. - Invite by email posts to the project-scoped branch of
POST /api/organisations/{id}/invitations(above), against the project's organisation rather than the active-organisation cookie. Its label options are the guest project roles (Project Customer,Project Approver). When the organisation has neither, or lacks theGuest Customerrole every invitation grants, the mode shows an error instead of a form. An email that already belongs to a member is added directly, and the dialog says so. The endpoint grants only the organisation's canonicalGuest Customer: the GLOBAL, unscoped default role. A scoped or custom role that shares the name is never granted, and when the canonical role is missing or ambiguous the endpoint answers404.
org.invitations.manage alone does not show the button. The server would accept
that caller's project invitation, but the dialog's label options come from the
collaborators GET, which returns roles only to projects.collaborators.manage
holders.
The project role is a label. It is stored on the collaborator row and no
permission check reads it, so choosing Project Approver over Project Customer grants nothing — tasks.approve included. What a person can do comes
from their organisation role; an invited outsider receives Guest Customer. The
card and the dialog say this next to the field, which reads Project label.
GET /api/projects/{id}/collaborators returns four additions to callers holding
projects.collaborators.manage (others receive an empty array, false and null ids):
| Field | Meaning |
|---|---|
pendingInvitations | PENDING project invitations whose email matches no collaborator's email, each with expired: true once past expiresAt (PT-1285). The token is never returned. |
data[].hasOpenInvitation | true when a redeemable invitation names this collaborator, whatever the row's status. Never set by an expired invitation. |
data[].openInvitationId | The id of that invitation, or null — what the card revokes or resends (PT-1210). |
data[].expiredInvitationId | The id of a PENDING invitation past its expiry that names this collaborator, or null — resendable and revocable, but it locks nothing (PT-1285). |
data[].revokeRemovesRow | true when revoking that invitation (open or expired) also removes this PENDING row, because the invitation wrote it (PT-1284). |
The card marks every PENDING row and every open invitation Pending. It
locks the label and Remove only on a PENDING row where hasOpenInvitation
is true: accepting the invitation upserts the row to ACTIVE with the invitation's
own label, so a change made before acceptance would be undone. A PENDING row
with nothing redeemable behind it — its invitation revoked or expired, or the row
created PENDING directly — stays editable and removable. An ACTIVE row can also
carry an open invitation (one that restores a lost organisation membership): its
label survives acceptance, but a removal would not, so Remove stays available
and its confirmation says that accepting the invitation adds the person back.
Revoking or resending a project invitation (PT-1210)
Every row an open invitation backs — a listed invitation, a locked PENDING
row, an ACTIVE row an invitation also names — carries Resend and
Revoke, for callers holding projects.collaborators.manage. Both call
project-scoped endpoints that accept projects.collaborators.manage or
org.invitations.manage in the project's organisation, plus access to the
project; every refusal is the uniform 403 { error: "Forbidden", code: "FORBIDDEN" }.
| Endpoint | Effect |
|---|---|
DELETE /api/projects/{id}/invitations/{invitationId} | The invitation becomes EXPIRED and its link stops working. Audited (invitation.revoked). |
POST /api/projects/{id}/invitations/{invitationId}/resend | The same invitation gets a new link and a fresh 7-day expiry, and is emailed again, once. Audited (invitation.resent). Once per invitation every 10 minutes, else 429. |
Both act on a PENDING invitation even when it is past its expiry date, and
answer 404 once it is accepted, revoked or expired. A resend whose email the
provider refuses still renews the invitation and answers emailDelivered: false.
An invitation past its expiry date that nobody opened is still stored as
PENDING, and the card shows it too (PT-1285): marked Expired when it is
listed on its own, and Invitation expired on a collaborator row it names,
with Resend and Revoke on both. It still blocks a new invitation to the
same email with 409, and Resend is what renews it. It never locks a row:
nothing can accept it, so a relabel or removal made meanwhile sticks.
Revoking — an open or an expired invitation — also removes the PENDING
collaborator row the invitation wrote, in the same transaction, and the
invitation.revoked audit entry names it; the response reports
collaboratorRemoved (PT-1284). Each row records the invitation whose creation
— or re-issue — last wrote it, so only that row goes: a PENDING row created
directly, one written before PT-1284, one another invitation wrote, and every
ACTIVE row are left in place — no longer locked — and Remove clears them.
The revoke confirmation says which of the two will happen.
Re-inviting the same email after a revoke re-issues the revoked invitation with
a new link (PT-1283).
Sample scenarios — a one-click example for an empty organisation (PT-1167)
An organisation with no STANDARD projects yet (the hidden HELPDESK project does not count) can install a complete example — the "Software delivery" scenario: an initiative, a stage-gated project with epics, stories, tasks and subtasks, a running cycle with its burndown and flow history, issues under SLA, deliverables, comments, time entries, attachments, feedback, and the linked Wiki space holding its specification, ADR and runbook.
| Route | What it does |
|---|---|
GET /api/sample-scenarios | One entry per catalog scenario: available, installed, or unavailable with a reason, plus wikiResidual |
POST /api/sample-scenarios/{scenarioId} | Install — the Wiki slice first, then Project Tracker's in one transaction; a failure removes the Wiki slice again (if that fails too, the next install converges on it) |
DELETE /api/sample-scenarios/{scenarioId} | Remove — Project Tracker's initiative and project (and anything added inside them), then the Wiki space |
- Who is offered it. The organisation holds no STANDARD project (the hidden HELPDESK project
does not count), and the caller holds
projects.create,initiatives.createandtasks.create. Removal needsprojects.deleteandinitiatives.delete. - Finishing a partial removal (PT-1174). Removal deletes Project Tracker's half first. If the
Wiki then refuses or cannot be reached, the Wiki space remains.
wikiResidualistruewhile the Wiki still holds that space, Project Tracker's half is gone, and the caller holds the removal permissions. The "Explore an example" prompt then offers Finish removing, which sends the sameDELETEwithresidualOnly: true. That flag makes the removal answer 409 and remove nothing if the example has been installed again since the page loaded. - Everything is the caller's. Every author, reporter and assignee is the installing user; no user is invented. Dates are relative to the day of install, so the example always looks current.
- Idempotent both ways. Every record's id is deterministic (
sampleScenarioResourceIdin@constellation/contracts), so a second install answersalready_installedand a second removalnot_installed. Install and removal serialise on a per-organisation advisory lock. - It respects the cycle mode. A
single-track organisation — the default for a new one — gets an organisation cycle; amulti-track one gets a project cycle. If the organisation's own tasks have joined an organisation cycle by the time the example is removed, that cycle is kept. - Audit and events. One
sample_scenario.installed/sample_scenario.removedcritical audit entry per operation, one routineCREATEentry per task, and oneprojects.sample_scenario.*event.
The design is SPEC-plt-sample-scenarios; the Project Tracker half is SPEC-pt-1167-sample-scenario-loader.
@mentions in descriptions and initiative comments (PT-1389)
Typing @ opens the mention picker in these editors:
- task comments;
- the task description;
- the initiative description and initiative comments;
- the project description (settings page and edit dialog).
A saved mention is stored as @[Name](userId) and renders as a chip.
Who the picker offers
- On a project or task, the picker uses
GET /api/projects/{id}/mentionable-users. - On an initiative, it uses
GET /api/initiatives/{id}/mentionable-users. That endpoint requiresinitiatives.readin the initiative's tenant. It offers:- the initiative's creator and delegates;
- the creators and collaborators of the projects the initiative spans;
- every ACTIVE member of the organisation, for full-access members only. A guest or scoped member never sees the organisation directory.
Who is notified
A mention notifies only users who can see what it names. On a project, that is
the project access rule. On an initiative, it is the initiative access envelope
plus initiatives.read in the initiative's tenant, so a guest who cannot open
the initiative is not emailed its title or text.
Notification types are unchanged:
- a description mention sends
mention_in_description, linking to the task, initiative or project; - a comment mention sends
mention_in_comment.
Editing a description notifies only the mentions the edit adds. Editing a comment notifies nobody. Mentioning an agent starts an agent run only in a task comment.
Provider sign-in (PT-1146)
The sign-in page (/projects, LoginForm) offers a provider sign-in (Google first) above the
password form when the build enables one. NEXT_PUBLIC_AUTH_FEDERATED_PROVIDERS lists the enabled
providers and is fixed at build time; unset, the page is exactly as before. The buttons come from
@constellation-platform/ui and the sign-in from the shared AuthProvider's signInWithProvider,
so Project Tracker holds no provider SDK (SPEC-plt-1520 D1).
A provider sign-in returns to the same validated destination a password sign-in would: the page's
?redirect=, otherwise the ?next= a refused sign-in carries back from the callback, otherwise
/dashboard. So a retry after a refusal goes where the first attempt was going, which for an
invitation keeps its fallback audit tenant (below). Sign-in stays invite-only: a provider links only to an account that
already exists, with the same verified email (D2, D3a).
The callback. The provider returns to GET /projects/auth/callback, which serves the shared
handler (createAuthCallbackHandler, D7), the same one Directory mounts. It exchanges the code,
checks next with the shared validator (isSafeInternalRedirect) and sets the session's cookies
only after the sign-in is audited. Refusals return to the sign-in page with a fixed code:
not_invited (Supabase refused to create an account), no_active_tenant (the account has no
tenant to audit against) or sign_in_failed. A standalone preview completes the round trip
through its own callback, because the callback URL is built from the deployment's own basePath.
Password-reset links keep their behaviour. The reset email still returns through this callback
with next=/reset-password. Such a sign-in now passes the same audit, recorded as AUTH_LOGIN
with provider: 'email'. A reset requested on the way to an invitation (the sign-in page's
"Forgot your password?" link passes its destination to /forgot-password) carries that page as
/reset-password's own next, so the audit tenant fallback below finds the invitation there
(/reset-password itself returns to the sign-in page afterwards until PT-1147 part 2). A
reset link for an account with no ACTIVE tenant and no such invitation is refused with
no_active_tenant. A link that no longer exchanges, or that GoTrue reports as expired, still lands
on /forgot-password?error=expired, with the invitation kept for the next request.
The audit. Project Tracker's writer (recordProviderSignIn) records, in one transaction
entered through projects.enter_tenant_scope, with module: 'projects':
AUTH_IDENTITY_LINKED(resourceauth_identity) for each provider identity Directory's link ledger (identity.record_provider_link) had not recorded, from whichever zone. The account's ownemailidentity is never recorded.AUTH_LOGIN(resourcesession), naming the provider and that identity's creation time inchanges.
Both go through auditCritical(), so each reaches audit.audit_entries with its
audit.entry.created event in the outbox. If the write fails, the sign-in is refused, the
request's session cookies are expired, the new session is revoked and the ledger rows roll back.
The audit tenant. An identity is audited against its own tenant while that tenant is ACTIVE,
otherwise against its earliest ACTIVE membership elsewhere (identity.resolve_identity_user).
With neither, as for an identity whose only tenant is suspended or a login with no identity row,
Project Tracker offers a fallback: the tenant of the invitation the sign-in returns to
(/invitations/<token>/accept), when that invitation is PENDING, unexpired, in an ACTIVE tenant
and addressed to the session's confirmed email. It is read through the existing by-token lookup.
Without one the sign-in is refused with no_active_tenant. Accepting the invitation from a
session with no identity row is phase 1b (PT-1144).
The no-access state. For a not_invited or no_active_tenant refusal the page shows
"No Constellation access" with a sign-out, instead of the form; for sign_in_failed it shows the
form with a failure notice. The page shows the same state for an unprovisioned session once
Project Tracker's /api/auth/me reports one (PT-1144, SPEC-plt-1520 AC 23).
Supabase clients. Every Supabase session client Project Tracker builds comes from
auth-next's factories (createConstellationSupabaseServerClient,
createConstellationSupabaseBrowserClient), so it talks to the resolved auth host under the
pinned session-cookie name (D8). The service-role admin client is the one exception: it holds no
session and stays on the project URL.
Source: src/app/auth/callback/route.ts,
served by src/server/tools/auth-callback.tools.ts;
the writer is src/server/tools/provider-sign-in.tools.ts.
Task model & advanced features
The task model and the smaller self-contained capabilities are documented on dedicated sub-pages so this page stays an overview:
- Work hierarchy & links — the Initiative → Epic → Story → Sub-task roadmap, cross-project epics (PT-446) and cross-cutting initiatives (PT-447), the Jira-compatible task fields (PT-375), parent-task rollup fields (PT-266), and typed lateral links (
TaskLink, PT-479). - Labels & goal views — the cross-project label goal view (PT-448), label autocomplete (PT-482), and the task-list label filter (PT-483).
- Flow engine — the dependency-gated ready set, the review queue, atomic
claim-next-ready, and the agent hard-gates (TASK_NOT_READY/WIP_LIMIT_REACHED). - In-product assignable agents — assigning or @mentioning an agent in an allowlisted tenant claims a run (
coordinator.agent_runs) and executes a bounded coordinator loop as that agent, and reports start / progress / result back into the task thread (PLT-296a/PLT-343 trigger + PLT-296b/PLT-344 runtime + PLT-296c/PLT-345 reporting). - Advanced features & configuration — customisable resolution outcomes + direct status actions (PT-314), initiative comments (PT-525), dashboard layouts (PT-87), the
RISKS_RESOLVEDgate criterion (PT-48), knowledge-base space references (PT-374), and the multi-orgx-act-as-orgheader (INF-143).
The Time Tracking page (PT-1466)
/time-tracking answers "how much, on what, when" for one period at a time. It reads the existing time-entry endpoints; the only API addition is overlapping=true on GET /api/time-entries, which selects the entries that overlap from–to (wherever they start) instead of those starting inside it.
- Periods and scope. Day, Week (the default) and Month, for your own time (Me) or the whole team's (Team; only for users who may see it). Previous / Next move by one period, and Today returns to the current one.
- Week is a timesheet: one row per task, grouped by project (Me) or by person (Team), one column per day, with totals on both axes. Durations read as hours and minutes (
1h 30m), never decimals. - Editing. A figure on one of your own rows opens the entries behind it. Each entry can be edited, lengthened or shortened by 15 minutes, or deleted after a confirmation. An empty cell logs time on that task and day. Another person's figures are read-only.
- Day lists one person's entries in clock order. Month is a calendar of daily totals, and a day opens it in the Day view.
- URL.
view(day/month; Week is the default and is not written),scope,projectIdanddate(written only for a day other than today). An old?view=listlink opens the Day view. - On a phone (below 768px): a strip of the week's days with their totals, the chosen day's entries, and Log time at the bottom. Log time logs a recent task in three taps and never overlaps your existing entries. Entry actions and filters open in bottom sheets.
- Narrow pages: below 1024px, or when the sidebar leaves the page too little room, the toolbar moves scope and project behind a Filters button.
- Retired with the old page: custom date ranges (the longest period is now a month) and the list of each teammate's individual entries. The Team view shows hours per person, task and day.
Per-tenant GitHub integration setup (PT-488)
The GitHub PR-lifecycle webhook — auto-transition to In Review on PR open, to the project's completed status on merge, and pr_link custom-field write — reaches a design partner's own repo through the GitHub App (below). Each connected repo maps to exactly one tenant and one project, so a partner's PT-12 can never touch another tenant's PT-12. PT-488's original per-repo webhook secret (SPEC-pt-488-per-tenant-github-webhooks) is retired since PT-1398.
The legacy route (/api/webhooks/github) since PT-1398. It authenticates with the global GITHUB_WEBHOOK_SECRET only, and scopes task/issue key resolution to GITHUB_WEBHOOK_ORG_ID. A payload naming a repository other than GITHUB_REPO_OWNER/GITHUB_REPO_NAME is rejected (401) before any signature check, and a delivery signed with any other secret, a retired per-row secret included, is rejected too. Once the signature verifies, the platform repository is rejected (401) while the platform tenant holds a projects.github_integrations row under its name, because the App route serves it; that read runs in the platform tenant's own scope, and a named repository is rejected when GITHUB_WEBHOOK_ORG_ID is unset or not a UUID. PT-1318 removes this route's env fallback.
Outbound sync: Task → GitHub through each integration (PT-1315). When a task's status changes, the transaction that writes it also queues a github-task-sync job, and a cron (/api/cron/github-task-sync/process, every minute) carries it out: it closes (as completed) or reopens the GitHub issues whose title or body references the task's [KEY], with a comment, to match the status the task holds when the job runs. A container task (an epic, or a story with sub-tasks) whose status the progress rollup derives from its children queues the same job in the rollup's transaction, so its issues close and reopen with it (PT-1404). A task reaches only its own organisation's GitHub App integrations that are active, have Task → GitHub turned on, and are mapped to its project or org-wide. Each repository is reached with a token minted for that one repository with only issues: write, revoked when the job is done; the search is restricted to that repository, so a key shared with another organisation never reaches its repository. A failed sync is retried with backoff, five attempts in all, each retry starting from a different repository, and then kept as a dead job with the failure's kind; the last failure is reported. Once a repository failing after the job ran out of time has used up those retries, the repositories it kept waiting go on as a fresh job. A sync GitHub rate-limits is not retried inside the limit: it continues as a fresh job once GitHub's wait has passed (at least a minute, doubling each time, at most an hour), up to six times, and only then fails like any other. A job that runs out of time partway (45 seconds into the drain) completes what it started, queues the rest as a fresh job that skips the repositories already in step and resumes a repository it left part-way after the last issue it dealt with, and is not charged an attempt for it (PT-1411). The deployment-wide GITHUB_TOKEN into GITHUB_REPO_OWNER/GITHUB_REPO_NAME now serves only the platform tenant (GITHUB_WEBHOOK_ORG_ID), and only while it holds no GitHub App integration — for Task → GitHub and for the Feedback Create GitHub issue action (POST /api/feedback/:id/sync-github) and Feedback status propagation alike, which move onto the App with PT-177. Every other organisation, and the platform tenant once it has an App integration, gets the same 503 as an unconfigured deployment from the Feedback action. PT_GH_TASK_SYNC_WEBHOOK_SKIP=true stops pull-request-driven syncs on that deployment-wide transport only; it never affects an App integration, nor the derived status change of a container the merge's cascade reaches: that status is derived from the container's children, never written by the pull request, even one that names it.
PT Issue → GitHub (PT-1415). With PT Issue ↔ GitHub on for a repository mapped to an Issue's project (or org-wide), the Issue detail offers Create GitHub issue; with several such repositories the dialog asks which one. POST /api/issues/{issueId}/github with { "integrationId": "<uuid>" } needs projects.issues.manage in the Issue's project and answers 202 (409 while a create for that repository is already queued): it queues a github-issue-sync job, and a cron (/api/cron/github-issue-sync/process, every minute) opens the GitHub issue and records the link, so the issue appears on the Issue detail within about a minute. The GitHub issue is titled [KEY] <title> and its body holds only a link back to the Issue and a marker; the description and comments are never copied, since the repository may be public. GitHub takes no request key for creating an issue, so each attempt first searches the repository for the marker and adopts an issue an earlier attempt left behind instead of creating a second one. From then on, resolving, closing or reopening the Issue queues the same job in the transaction that changes it, and the job closes or reopens the linked GitHub issues to match: closed as completed for a resolution, closed as not planned for Won't fix, Duplicate or a plain close, reopened for any active status; a GitHub issue that already shows that state is left alone. No comment is posted. A link reaches GitHub only while its integration is active, on a claimed installation, has PT Issue ↔ GitHub on and is mapped to the Issue's current project or org-wide; after a move to a project the repository is not mapped to, the link stays on the Issue, shown as not syncing. GET /api/issues/{issueId}/github lists the Issue's links, each with its last sync outcome, and the repositories it can still create one in. Both the request and the recorded link are audited with auditCritical(). Failures are retried with backoff, five attempts in all, and the last one is reported.
The secret mode is retired (PT-1398). Project Tracker's GitHub integration runs through a GitHub App (SPEC-multi-tenant-github-sync, PT-1314), connected with proof of repository ownership. PT-488's per-row webhook secrets no longer authenticate anything: the secret script, the encryption helper and convert-back are gone, and no code reads or writes webhook_secret_encrypted or key_version. The two columns stay in projects.github_integrations, unread, until PT-1401 drops them one release later, and with them GITHUB_INTEGRATION_ENCRYPTION_KEY, which nothing reads any more.
The GitHub App receiver (PT-1352). App deliveries arrive at POST /api/webhooks/github/app (https://constellation.planetb2b.com/projects/api/webhooks/github/app in production), set once per environment as the App's webhook URL. The route:
- verifies
X-Hub-Signature-256againstGITHUB_APP_WEBHOOK_SECRET, orGITHUB_APP_WEBHOOK_SECRET_NEXTduring a rotation, before reading anything — an unverified delivery, or any delivery while no App secret is set, is401; - resolves the delivering installation's tenant through
projects.resolve_github_installation(ADR-071), called fromsrc/lib/github-app-delivery.tsonly —npm run check:github-resolver-call-site(pre-push gate 35) fails the build on any other call site; - enters that tenant through the ADR-044 door, and reads the integration by installation binding and numeric repository id, so a repository resolves only to an integration of the installation that delivered it.
installation (suspend, unsuspend, deleted), installation_repositories (removed) and repository (renamed, transferred, deleted) events move the installation binding and its integrations through the states in the spec's D4, each change audited with auditCritical() in its transaction. A suspend or unsuspend applies the state GitHub reports for the installation when it arrives, not the delivered action, so a late or redelivered one cannot undo a newer one; when GitHub cannot be read, the delivered action applies (PT-1411). A removed repository's name is parked (parked/<row id>), except the env-pinned platform repository's. pull_request events are processed only for an ACTIVE integration of a CLAIMED installation with Inbound PR on, under the same key-resolution rules as the legacy route (the integration's tenant, and, when it has a project, that project and its path rules' projects, PT-1486). issues events (PT-1414) are processed only with Inbound issues on, and change only a Project Tracker Issue linked to that GitHub issue through this integration's issue links (projects.github_issue_links, keyed by integration and issue number, never by the number alone): a close resolves the Issue (FIXED for a completed close or one with no reason, WONT_FIX for not planned, DUPLICATE for a duplicate), a reopen reopens a resolved or closed Issue, and a close never replaces the resolution of an Issue that is already resolved or closed. An integration mapped to one project changes only that project's Issues: an Issue moved to another project after it was linked receives nothing through it (PT-1415). A GitHub issue linked only to a Feedback that was never promoted receives nothing; Feedback status no longer follows GitHub on the App route. Everything else is acknowledged with 200 and a skipped reason, and logged. The App's other variables (GITHUB_APP_ID, GITHUB_APP_SLUG, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_PRIVATE_KEY_NEXT, GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET) are declared in turbo.json; the connect flow below and the outbound client (PT-1315) read them.
Connecting a repository through the App (PT-1353). A tenant admin holding projects.github_integrations.manage connects in three legs, each tied to one single-use, ten-minute connect request of that organisation and that user:
POST /api/github/connect-requestswith{ "projectId": "<uuid>" }or{ "orgWide": true }answers201with aredirectUrl: GitHub's install page for the App. For an installation the organisation already holds,installationIdskips straight to GitHub's authorization page (to add repositories, or re-map a removed one).- GitHub returns the browser to the App's Setup URL,
GET /api/github/app/setup(https://constellation.planetb2b.com/projects/api/github/app/setupin production). It records the installation the browser claims on the open request and continues to GitHub's authorization page.setup_action=request(an owner must approve the install first) changes nothing. - GitHub returns to the App's OAuth callback,
GET /api/github/app/oauth/callback(…/projects/api/github/app/oauth/callback). It consumes the request, then proves, with the user's authorization, that the user sees the installation and owns its account or is an active admin of its organisation. It revokes that authorization before writing anything, and refuses the connect if the revocation fails. Then it binds the installation to the organisation and maps every repository the user administers, with only Inbound PR on. A repository already connected to another organisation is not mapped and is reported as "already connected to another organisation; contact support". So is the platform repository, for any tenant but the platform tenant, until PT-1318. The organisation's own secret-mode row for the same repository, should one remain, is converted to the App; its ciphertext stays, unread, until PT-1401 drops the column. It then stores what was connected and what was not, and why, on the connect request, and sends the browser to/settings/organisation/github?connect=<request id>, which shows it (PT-1380). When that cannot be stored in time, the answer is a page listing it instead, which says it was not saved. A refused leg answers a page with the reason.
Every step is audited with auditCritical() in its transaction (github_connect_request.*, github_installation.claimed, github_integration.mapped, .remapped and .converted), correlated by the connect request id. The user's token is never stored, logged, audited or returned. The connect flow needs GITHUB_APP_ID, GITHUB_APP_SLUG (the App's URL name), GITHUB_APP_PRIVATE_KEY, GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET and GITHUB_APP_WEBHOOK_SECRET; with any of them missing every leg answers 503. Register both URLs on the App, keep "Request user authorization (OAuth) during installation" off and "Redirect on update" on, and keep "Expire user authorization tokens" off. With it on, GitHub also issues a refresh token, so revoking the user token deletes the user's whole authorization of the App and they are asked to authorize it again on their next connect; no credential is left behind either way (PLT-1853).
Managing integrations: Settings → Organisation → GitHub (PT-1354, PT-1488). The GitHub page under Organisation settings (/settings/organisation/github; the earlier /settings/github redirects there, query string included) lists the organisation's installations and their repositories, and starts Connect: a target project or org-wide, and a new installation or one already held. It is shown only to a caller holding projects.github_integrations.manage through a global role, decided by the listing route itself, since the client's permission list cannot tell a global grant from one scoped to a project. After a connect it shows that connect's result above Connect: the repositories connected, and the ones skipped with why (for example "You are not an admin of this repository" or "Already connected to another organisation"). The result is read through GET /api/github/connect-requests/{id}, answered only to the user who ran the connect, in its organisation, for seven days; anything else is 404. It holds repository names and skip reasons only, never a token. Dismiss hides it. Through its routes a tenant admin can:
- map a repository to one project, or explicitly org-wide. Org-wide resolves each task key in a pull request to its own project, which is what a monorepo needs:
[PT-12]moves a Project Tracker task and[CAT-3]a Catalog one. A repository mapped to one project moves only that project's tasks; the page says which applies. The project must belong to the same organisation, and since PT-1357 the database refuses any other, whoever writes the row; - switch each capability separately. Inbound PR, Inbound issues (PT-1414), Task → GitHub (PT-1315) and PT Issue ↔ GitHub (PT-1415) can be turned on and off. The App asks GitHub for
issues: write, but Project Tracker uses it only for a repository with a write capability on; - disable an
ACTIVEintegration and re-enable aDISABLEDone while its installation isCLAIMED. Neither parks the repository name, so the legacy route stays closed; an integration an operator parked is re-enabled only after the operator unparks it; - refresh an installation: Project Tracker reads it from GitHub through the App JWT and applies a suspension or unsuspension, then reads its repositories ten at a time and applies a removal or a rename, as the deliveries would have. This catches up the deliveries dropped while the organisation was not active (ADR-071 § D1). A 404 for the installation is never taken as an uninstall: the refresh is refused and changes nothing. One refresh of an installation runs at a time, and a new one starts at least a minute after the last one finished (PT-1381): a refresh refused for either reason calls nothing on GitHub, and the page says so and when to try again.
- edit a repository's path rules (PT-1490). Each integration has a Path rules section that maps a prefix to a project (see below). The rules are edited as a draft and saved together with one Save rules, as one replace-set. Discard drops the draft. Each prefix is checked as you type, with the server's own normalisation: an invalid prefix, a prefix another rule already has, or a rule with no project blocks Save and says why. Add rule stops at 50. A line under the rules states the default, either the integration's own project or org-wide. Test a path shows which project a path resolves to, by which rule, and shows nothing while the draft is invalid. A rule whose project is not
ACTIVEshows its status; a rule whose project the admin cannot read keeps it asProject not accessibleand saves it back unchanged. An integration the admin cannot edit lists its rules read-only.
Mappings and capabilities can be changed while an integration is ACTIVE, DISABLED or SUSPENDED; a DISPUTED one is frozen for the operator, and a REPOSITORY_REMOVED one is mapped again through Connect. Every change is audited with auditCritical() in its transaction (github_integration.settings_changed, .state_changed), attributed to the admin; a request that changes nothing writes nothing.
Path rules: one repository, several projects (PT-1486). An integration also carries up to 50 path rules, each a directory (or file) prefix of the repository mapped to a project of the organisation (SPEC-pt-1489-github-path-rules). A path belongs to the project of the longest prefix that contains it, with directory semantics (apps/catalog covers apps/catalog/src/page.tsx, never apps/catalog-ui/x), and otherwise to the integration's own project, or to no project when it is org-wide. A prefix is stored as written after one leading ./, any leading / and one trailing / are stripped; anything else that is not plain /-separated segments (an empty, . or .. segment, a backslash, a control character, more than 512 characters) is refused, never rewritten. PATCH /api/github/integrations/{id} with pathRules: [{ pathPrefix, projectId }] replaces the whole set ([] clears it), alone or with a mapping or capability change; each added or changed rule's project must be one the admin can reach. The change is audited in one github_integration.settings_changed entry listing the rules added, removed and changed. The listing returns each integration's rules, sorted by prefix, each with its project's name, key and status; a rule whose project the admin cannot read shows only its id, as Project not accessible, and is kept when the set is saved back. Deleting a rule's project deletes the rule; re-mapping a removed repository through Connect clears its rules. A project-mapped integration's pull requests also resolve the keys of its rules' projects, for tasks and issues: a repository mapped to X with apps/y → Y completes [Y-12] as it completes [X-3], and still reaches no other project of the organisation. Rules never widen Task → GitHub or PT Issue ↔ GitHub: those still follow the integration's own project or org-wide.
Pull requests attributed by their files (PT-1487). On an integration with at least one path rule and Inbound PR on, an opened or merged pull request is also attributed to the projects its changed files belong to, whether or not it names a key (SPEC-pt-1487-github-pr-path-attribution). After the delivery has been handled and answered as before, a background job (/api/cron/github-pr-attribution/process, every minute) lists the pull request's files through a token minted for that one repository with pull_requests: read only, revoked when the listing ends, and maps every path, both sides of a rename, through the rules. The result is kept as one record per pull request: complete, partial (GitHub lists at most 3000 files; a listing that ran out of time or met a moving pull request is partial too) or unknown (the files could not be read; a timeout, a rate limit or a GitHub outage is retried first). A merged attribution is never replaced by a later opened one. Each task the pull request names gets one activity entry, from System, saying which projects it touched: this task's project, and how many others, never their names, since a reader of the task may not see them. An integration without rules makes no new GitHub call and behaves exactly as before. The App registration must hold the pull_requests: read repository permission (SPEC-multi-tenant-github-sync § D2); without it every attribution is unknown. The signal for a pull request whose key belongs to a project its files do not touch is PT-1574.
Resolving a path (PT-1486). GET /api/github/mappings/resolve answers which project a path of a connected repository belongs to, for any member of the organisation: name the repository by exactly one of repositoryId (GitHub's numeric id) and repository (owner/name, any letter case), and give path (absent or empty is the repository root). The answer carries the repository, the integration and its state, the normalised path, match ({ "kind": "rule", "pathPrefix": … } or { "kind": "default" }), the project (null when the default is org-wide) and orgWide. A DISPUTED integration is reachable by repositoryId only. A repository the organisation has not connected, another organisation's repository, and a path whose project the caller cannot read all answer the same 404, so the endpoint discloses none of them; a malformed query is 400. Answers are never cached.
Delivery health (PT-1317). Each repository on the settings page shows whether GitHub is delivering its events: No deliveries yet, Deliveries healthy, or Deliveries failing, with the time of the most recent delivery and, when it was ignored or failed, a short reason. The listing and every settings response carry it as health. Project Tracker records a pull_request or issues delivery only after the App signature verified and the delivery resolved to that repository's integration through its installation, so a delivery that fails verification or names a repository that is not connected changes no organisation's health; those still appear only in GitHub's own delivery log. A delivery is keyed by its X-GitHub-Delivery id, so a redelivery from GitHub updates the same entry rather than adding one. A delivery that was ignored by configuration (the capability is off, or the integration is disabled) counts as delivered, not as failing; health reads failing when the most recent attempt was rejected or could not be applied. Only the delivery id, the event name, the outcome and its reason code, and two timestamps are stored, never a payload or a credential, and only the 50 most recent deliveries of each repository are kept. Recording happens after the event has been applied and cannot change its result: if the record cannot be written, the delivery is answered exactly as it would have been. The legacy webhook (/api/webhooks/github) reports no health.
GitHub App runbook (PT-1355)
Registering the App, once per environment. Each environment (production, staging, every preview that receives App deliveries) has its own Project Tracker GitHub App. It is a separate registration from the coding-run App (SPEC-multi-tenant-github-sync § D2), so a PT installation can never push code.
| Setting | Value |
|---|---|
| Repository permissions | Metadata: Read, Pull requests: Read, Issues: Read and write |
| Organization permissions | Members: Read (the connect flow's admin check) |
| Subscribed events | Pull request, Issues, Repository. installation and installation_repositories reach every App without a subscription. |
| Webhook URL | https://<host>/projects/api/webhooks/github/app (production: https://constellation.planetb2b.com/projects/api/webhooks/github/app). Webhook active, content type application/json. |
| Webhook secret | the value of GITHUB_APP_WEBHOOK_SECRET |
| Setup URL and Callback URL | Setup URL https://<host>/projects/api/github/app/setup, Callback URL https://<host>/projects/api/github/app/oauth/callback (PT-1353) |
| "Redirect on update" | on, so changing an existing installation's repositories returns to the Setup URL |
| "Request user authorization (OAuth) during installation" | off: with it on, GitHub skips the Setup URL and the connect flow loses its state |
| "Expire user authorization tokens" | off, so the user token comes with no refresh token |
| Where can this App be installed | Any account |
Then provision the project-tracker zone's variables in Vercel, each one declared in the root turbo.json build.env (a variable missing there is stripped from the build):
GITHUB_APP_ID,GITHUB_APP_CLIENT_ID: from the App's settings page;GITHUB_APP_SLUG: the App's URL name, the last segment ofhttps://github.com/apps/<slug>. Without it every connect-flow leg answers503;GITHUB_APP_PRIVATE_KEY: a PEM private key generated on that page;GITHUB_APP_CLIENT_SECRET: a client secret generated on that page;GITHUB_APP_WEBHOOK_SECRET:openssl rand -hex 32, the same value as the App's webhook secret;GITHUB_APP_PRIVATE_KEY_NEXT,GITHUB_APP_WEBHOOK_SECRET_NEXT: empty, except during a rotation.
A blank value counts as unset. With no GITHUB_APP_WEBHOOK_SECRET, every App delivery is 401.
Rotating a credential. None of these needs downtime.
-
Webhook secret. Set
GITHUB_APP_WEBHOOK_SECRET_NEXTto a new value and redeploy. The route now accepts both. Change the App's webhook secret on GitHub to the new value. Then move the new value intoGITHUB_APP_WEBHOOK_SECRET, clear_NEXT, and redeploy. The private key and the client secret are read by the connect flow (PT-1353) and the outbound client (PT-1315). -
Private key. Generate a second key on the App's settings page (GitHub keeps several). Set it as
GITHUB_APP_PRIVATE_KEY_NEXTand redeploy: a token request GitHub refuses with the current key is retried once with the next one. Move it intoGITHUB_APP_PRIVATE_KEY, clear_NEXT, redeploy, then delete the old key on GitHub. -
Client secret. GitHub keeps two client secrets at once. Generate the new one, replace
GITHUB_APP_CLIENT_SECRET, redeploy, then delete the old one on GitHub. A connect flow in flight during the redeploy fails and is simply retried.
Renaming the platform repository on GitHub needs GITHUB_REPO_OWNER / GITHUB_REPO_NAME updated in the same step. The App route recognises the platform repository by comparing those variables (lowercased) with the integration's live name. Until they follow a rename, the platform repository loses its no-automatic-parking exception, and the operator script refuses to park or unpark it.
The operator script. Platform-operator transitions run as apps/project-tracker/scripts/github-integration-operator.ts, never through a route. Run it from apps/project-tracker, on the app's own DATABASE_URL:
npx tsx scripts/github-integration-operator.ts <command> \
--tenant <tenant uuid> --installation <GitHub installation id> \
[--repository <GitHub repository id>] --reason "<support case, and why>" [--apply]
Without --apply nothing is written: the command reads, locks and plans exactly as it would, and prints the change. Run it once without, read the plan, then again with --apply. Each applied change writes an auditCritical() entry in its transaction, carrying the command and the --reason; a refused command writes nothing. There is deliberately no npm run wrapper, because npm swallows flags it does not know.
The tenant comes from the support case. The script never looks up which tenant holds an installation or a repository: it enters the named tenant through the ADR-044 door, and an installation or repository that is not live there is refused, with the same message whether or not it exists in another tenant. A tenant that is not ACTIVE is refused too. The GitHub ids are on the installation's settings page (/settings/installations/<id>) and in GET /repos/<owner>/<repo> (id).
| Command | Acts on | Transition |
|---|---|---|
dispute-binding | binding | any state but RELEASED → DISPUTED. The binding stops serving content events; GitHub's lifecycle events keep moving the state the dispute closes to. |
close-binding-dispute | binding | DISPUTED → the state it closes to: the one it was disputed from, as GitHub has moved it since |
release-binding | binding | DISPUTED or DELETED → RELEASED, freeing the installation id. Refused while any integration of the binding is not RELEASED: release each first. |
dispute-integration | integration | any state but RELEASED → DISPUTED |
close-integration-dispute | integration | DISPUTED → its prior state. Landing on REPOSITORY_REMOVED parks the name (except the platform repository's). Otherwise the row takes GitHub's current name; if another row still holds that name, the close is refused. |
release-integration | integration | DISPUTED or REPOSITORY_REMOVED → RELEASED, name parked, freeing the repository id and name for another claim. Detaches the integration's GitHub issue links in the same transaction. |
disable-integration | integration | ACTIVE → DISABLED |
park-integration | integration | the platform repository's row only: name → parked/<row id>. ACTIVE/DISABLED → DISABLED; SUSPENDED stays, returning to DISABLED on unsuspend; REPOSITORY_REMOVED stays. Refused while DISPUTED. |
unpark-integration | integration | the platform repository's parked row: its live name restored, state unchanged. Re-enabling it is a tenant admin's act in settings. |
Copying the platform Feedback links onto the App (backfill-feedback-issue-links, PT-1414). Feedback's Create GitHub issue action recorded each GitHub issue number on the Feedback itself. Once Planet B2B's platform repository has its App integration (INF-761), this command copies the platform tenant's Feedback links onto that integration's issue links, so issues events reach them through the App route; a Feedback already promoted to an Issue gets that Issue's link, the others keep only the Feedback (they receive nothing inbound, and PT-179 detaches them):
npx tsx scripts/github-integration-operator.ts backfill-feedback-issue-links \
--tenant <Planet B2B tenant uuid> --installation <id> --repository <id> \
--reason "PT-1414 backfill" # read the counts, then re-run with --apply
It is refused for any tenant but GITHUB_WEBHOOK_ORG_ID and any repository but GITHUB_REPO_OWNER/GITHUB_REPO_NAME, because those are the only ones the links were made against. It prints how many Feedback carry a GitHub issue, how many links it creates (and how many on promoted Feedback), how many a Feedback promoted since an earlier run now attaches to its Issue, how many are already linked, and how many numbers another link of the integration already holds (left alone). A re-run creates nothing. The links other tenants' Feedback carries (the defect PT-1320 contained) are invisible under the tenant and never migrated; counting them needs PT-1416's census.
Counting secret-mode integrations (count-secret-mode, PT-1396). PT-1398 removed the legacy route's secret-mode branch once the count of enabled secret-mode integrations read zero (PT-1321); the command stays until PT-1401 drops the secret columns, to confirm the count before the drop. It reports, across every tenant, counts only:
npx tsx scripts/github-integration-operator.ts count-secret-mode
# [github-integration-operator] Secret-mode integrations: enabled 0, disabled 3
It takes no argument at all (--tenant, --reason and --apply are refused), prints no tenant, repository or row id, and writes nothing, not even an audit entry: it runs one READ ONLY transaction that calls projects.count_secret_mode_github_integrations(), a definer whose owner can read only the mode and enabled columns (ADR-073). Suspended tenants are counted too. It exits 0 whatever the counts. A secret-mode row serves nothing since PT-1398; to act on one, reconnect the tenant's repository through the App.
The INF-761 cutover, and its rollback. Cutting Planet B2B over to the App:
- Install the production App on
B2B-Online/constellation, connect it through the normal connect flow, map it org-wide, and enable Inbound PR. From the moment the App row exists, the legacy route refuses the repository with401, so run the next step straight away. - Copy the platform Feedback links onto the new integration with
backfill-feedback-issue-links(above), then turn on Inbound issues for it in settings, so GitHub issue events keep reaching the Issues those Feedback were promoted to. Anissuesdelivery acknowledged before both are done is never redelivered. - Only then deactivate (do not delete) the repository's legacy webhook (Settings → Webhooks).
- Watch INF-761's exit criterion: 14 consecutive days with at least one real PR-open and one real PR-merge through the App route, zero App authentication or tenant-resolution failures, and zero fallback-path log hits.
GITHUB_WEBHOOK_SECRETstays provisioned until PT-1318.
To roll back, park the platform row, then reactivate the legacy webhook:
npx tsx scripts/github-integration-operator.ts park-integration \
--tenant <Planet B2B tenant uuid> --installation <id> --repository <id> \
--reason "INF-761 rollback: <why>" # read the plan, then re-run with --apply
The parked row no longer carries the repository's name, so the platform repository looks unconfigured and reaches the identity-gated env fallback exactly as before the cutover. To roll forward again, unpark-integration, deactivate the legacy webhook, and have a tenant admin re-enable the integration. A partner has no rollback to secret mode since PT-1398.
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 / initiative progress. - GitHub App webhook:
apps/project-tracker/src/server/webhooks/github-app-webhook.ts— the App receiver's pipeline; the PR and issues handlers both routes share live insrc/server/webhooks/github-webhook-handlers.ts. - GitHub webhook:
apps/project-tracker/src/app/api/webhooks/github/route.ts— task auto-close regex/\[([A-Z][A-Z0-9]{1,9}-\d+)\](?!\()/gi. - OpenAPI source:
apps/project-tracker/src/lib/openapi.ts.
9. Known exceptions / pitfalls
- Auth redirect destinations are internal paths.
/auth/confirmaccepts anextpath beginning with a single/, with no//,@, backslash or colon, that parses to the request origin./auth/callbackuses the platform's shared validator (isSafeInternalRedirect, PT-1146): a single leading/, no//or/\prefix, no tab or line break, and it must parse to the request origin. Invalid or missing destinations use/dashboardfor callback and/for confirmation; accepted query strings and fragments are preserved. Validation applies even when the handler's basePath is empty. - The sign-in page's
redirectparameter, and thenexta refused sign-in carries back, follow the same rules. The page leaves only once sign-in is complete, including two-factor verification when the account has it: the sharedsignInresolves to'complete'or'mfa-required'. It then parses the value against its own origin and navigates to the parsed path, query and fragment only when the origin matches and the parsed path begins with a single/. Otherwise, or when the path is the sign-in page itself, it goes to/dashboard.router.push()re-parses its target with the build basePath, which is empty outside production, so validation does not rely on the/projectsprefix. Recently viewed search entries are held to the same rule. - 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-key footgun — narrower than it used to be. The GitHub webhook matches a bare
[KEY-123]anywhere in a PR title or body and auto-closes that task on merge, so a release body listing several bare bracketed keys closes all of them. It no longer matches the markdown-link form[KEY-123](url):BRACKETED_KEY_REFERENCE_REis/\[([A-Z][A-Z0-9]{1,9}-\d+)\](?!\()/gi, whose(?!\()negative lookahead was added by PT-345 precisely so that reviewers' pasted hyperlinks are references rather than close markers. (Note the key shape too: two-to-ten characters, letters and digits only — an underscore never matched, contrary to what several older copies of this regex in the docs claimed.) Prefer plainINF-30when you only want to reference — it is unambiguous and does not depend on that lookahead — but do not assume a[KEY](url)link will close a ticket, because it will not. - The branch counts on merge too, for one case (PT-1142). Opening a PR reads task keys from the branch as well as the title and body: it writes
pr_linkand moves the task into review. The merge now also completes a branch-named task when itspr_linkis this PR and it is still in a review status. That is what closes a PR titled… (PT-1108), with parentheses rather than brackets, which used to leave its task in review for good. To keep such a task open after the merge, move it out of review before merging. Dropping the key from the title is not enough when the branch names it. - A failed merge completion answers 500 (PT-1142). When completing a task or resolving an issue throws, the webhook still processes the rest of the PR, reports the error to Sentry, and answers
500with the keys infailed. The delivery then shows as failed under Settings → Webhooks → Recent deliveries. A race is still askippedno-op: an issue resolved or deleted in the meantime, or a task deleted. Redelivering re-applies the whole merge, so anything someone reopened since the first delivery is completed again. Redeliver promptly, or complete thefailedkeys by hand. - Identity reads are raw SQL; identity writes go through Directory. Don't try to add Prisma relations from
projects.*toidentity.*— they are in different schemas. UseIdentityUserRepo/IdentityPermissionRepoto read. To mutate identity, call one of the Directory-ownedSECURITY DEFINERfunctions (identity.grant_invitation_access,identity.provision_user,identity.merge_user_metadata,identity.update_organisation_profile,identity.bootstrap_tenant) rather than writing raw SQL —npm run check:identity-writesfails the build otherwise. Adding a new one means a new Directory migration; see ADR-025. - Zod is unified on v4 (INF-862). Project Tracker and shared packages use the unified Zod 4 dependency. Import from
zodand use the v4 surface (object-formz.enum,{ error: '...' }). The generated OpenAPI document preserves authored request and response constraints, including integer bounds, string patterns, array lengths, and descriptions. Compatibility timestamp schemas retain minute precision as well as seconds; custom validation messages remain available in flat field errors. - Don't hand-roll
SET LOCALin raw SQL. A bareSET LOCALoutside a transaction is a no-op, and through the transaction-mode pooler a standalone statement can land on a different server connection than your next query. The helpers (withTenantContext,runRawWithTenant) runSELECT set_config('app.tenant_id', $1, true)inside the interactive transaction on the pooledDATABASE_URL— transaction pooling pins the whole transaction to one server connection, so this is safe (ADR-019).DIRECT_URL(Supavisor session mode, port 5432) is for Prisma CLI migrations, and local scripts (seed / setup) — but never a tenant-scoped request path. TheCRON_SECRET-gated cron routes connect onPROJECTS_CRON_DATABASE_URLinstead, the confined cron login (ADR-070). - Open a tool's transaction with
runInTenantTransaction, not a bare$transactionthat sets the GUC (PT-1031, PT-1206). Under transaction-scoped RLS, the production default, the tenant-scoping extension runs every tenant-scoped model op outside an ambient-transaction span in a NEW transaction on a second connection. That holds even when the op is issued on yourtx, and even after you setapp.tenant_idontxyourself. Raw statements stay ontx. So the model writes commit apart from the raw audit or outbox insert. A model write that touches a rowtxhas locked raw also waits on that lock until the transaction times out: milestone PATCH, issue move and tag deletion all did.runInTenantTransactionsets the GUC and enters the span. A structural test (TOOLS_BARE_TRANSACTION_SITESintransaction-inventory.test.ts) fails on a bare call insrc/server/tools/, unless its function is listed with a reason. - Task links are tenant-scoped, not project-scoped (PT-909). A typed link (
BLOCKS,RELATES_TO,DUPLICATES,FOLLOWS, …) may span two projects in the same tenant, and moving either endpoint between projects preserves it. Three consequences worth knowing before you write againsttask_links:task_links.project_idis a denormalization of the SOURCE task's project, not a partition key. Do not filter a link read by it — a database trigger repoints it when the source task moves, so filtering by it silently drops every edge whose source has moved. Scope link reads by their endpoints (source_task_id/target_task_id) instead.- A
BLOCKSpredecessor in another project still blocks. Readiness is evaluated one project at a time, so a foreign predecessor is absent from the local status map and must have its doneness resolved explicitly; otherwise a task is either wrongly ready or wrongly pinned out forever.loadBlocksEdgesForTasksdoes this for PT, and the coordinator does it for whatever rows its host injects. - Both endpoints are authorized. Creating or deleting a cross-project link requires access to the OTHER endpoint's project too — a
BLOCKSedge changes what is ready there. An unauthorized or absent target returns the same error, so the check is not an existence oracle.
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. Also reports the project's story-point scale, or an explicit unknown. |
get_project_overview | pt get-project-overview | Project details including stages, custom statuses (each with its category and approval-ready flag) and the story-point scale (the per-project values estimatePoints may take). Full when the caller may read the project; a caller who may only create tasks in it gets a labelled REDUCED result carrying the name, the scale and the stages. An unreadable scale reports as unknown, never as a default. |
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). |
update_project_status | pt update-status | Edit one of a project's custom task statuses — category, label, hexColor or isApprovalReady (INF-715); not the project's lifecycle status, which update_project sets. statusId is the status id or its name, matched case-insensitively; an unknown name is refused and the project's names are listed. At least one field is required. A rename is deliberately not offered (tasks store their status by name). Needs statuses.update. |
delete_project | pt delete-project | Delete a project. Irreversible and cascades — permanently deletes ALL tasks, issues, stages and comments, and the project's files with their stored blobs; a file still attached elsewhere is kept, and time entries are kept, unlinked from their task. Files that deletions before PT-1172 left behind in the tenant's own storage are removed by the PT-1307 post-deploy repair; its dry run also counts the comment-linked, feedback-folder and other-bucket files it leaves. |
Task tools
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
list_tasks | pt list-tasks | List tasks with filters (status, assignee, parent, ready / review). Its search is a per-project whole-phrase title match — for duplicate checking use search_tasks. |
search_tasks | pt search | Tenant-wide ranked search (PT-652) across tasks including subtasks, issues and feedback — the duplicate check to run before create_task / create_issue (PT-1020 / INF-550). |
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). |
list_epic_steps | pt list-epic-steps | List an epic's steps (PT-1423) in order, each with its "finished when" sentence and derived state, and its tickets in waves derived from BLOCKS links. Tickets in projects you can't read are counted, never shown. |
add_epic_step / update_epic_step | pt add-epic-step / pt update-epic-step | Append or edit a step; "finished when" is required. Needs tasks.update.any in the epic's project. |
archive_epic_step | pt archive-epic-step | Archive a step (never deleted); its tickets are detached. |
reorder_epic_steps | pt reorder-epic-steps | Put the active steps in a new order — the list must name every active step once. |
list_cycle_steps | pt list-cycle-steps | The epic steps a cycle sets out to close (PT-1424): each pick with its state, its baseline flag and, once the cycle closed, the result the close froze; the steps it can still pick. Same scope arguments as update_cycle. See the steps a cycle closes. |
list_initiative_milestones | pt list-initiative-milestones | An initiative's milestones (PT-1427): each with its due date, state and grouped epics — whether each goal is accepted — plus the epics a milestone can group. Epics you cannot read are counted, never shown. |
create_initiative_milestone / update_initiative_milestone / archive_initiative_milestone | pt create-initiative-milestone / pt update-initiative-milestone / pt archive-initiative-milestone | Create, correct or archive an initiative milestone; update also marks it reached (refused unless every grouped goal is accepted) or reopens it. Epics are task keys or UUIDs. Needs initiatives.update. |
pick_cycle_step / unpick_cycle_step | pt pick-cycle-step / pt unpick-cycle-step | Pick a step for a cycle (optional note) or remove a pick. A pick made before the cycle starts becomes its baseline and cannot be removed afterwards; a completed cycle's picks are read-only. Needs the cycle's update permission and read access to the step's epic. |
get_task | pt get-task | Get full task details. Includes a comment count (plus the newest timestamp) and a pointer to list_task_comments — read the thread before starting work; it carries decisions and deferrals the description does not. Bodies are deliberately not rendered here, and --json returns the same summary rather than the raw comment array: list_task_comments is the surface that applies the untrusted-text boundary to comment content. |
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). PT-1020: search first — without duplicatesReviewed (CLI --duplicates-reviewed <csv>), a create matching similar open tasks anywhere in the tenant is answered 409 with the candidate list instead of creating. |
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). |
accept_epic_goal | pt accept-epic-goal | The approver accepts an epic's goal at the goalRevision they read (PT-1426). withdraw_epic_goal_acceptance / pt withdraw-epic-goal-acceptance withdraws it. update_task sets the goal and the approver (goal, goalApproverId; CLI --goal, --goal-approver-id); get_task prints them. See epic goal and approver. |
claim_next_ready_task | pt claim-next-ready | Atomically claim the top ready task in a project (projectId + assigneeEmail). Race-free; returns nothing to claim at the agent WIP cap. |
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. |
Comment tools (INF-52)
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
list_task_comments | pt list-task-comments | List a task's comments; newest-first by default, with configurable order and limit. Flags the files each comment embeds (files:). |
add_task_comment | pt add-task-comment | Add a task comment; notify project-visible users with MCP mentionUserIds or CLI --mention-user-ids. |
update_task_comment | pt update-task-comment | Edit under PT's author-or-comments.update permission rules. |
delete_task_comment | pt delete-task-comment | Soft-delete under PT's author-or-comments.delete permission rules. |
list_issue_comments | pt list-issue-comments | List issue comments; supports order, limit, and staff-only notes via includeInternal. Flags the files each comment embeds (files:). |
add_issue_comment | pt add-issue-comment | Add an issue comment; staff-only notes use MCP isInternal or CLI --internal, and MCP attachmentIds or CLI --attachment-ids link up to 20 files. |
update_issue_comment | pt update-issue-comment | Edit one of the authenticated user's issue comments. |
delete_issue_comment | pt delete-issue-comment | Soft-delete one of the authenticated user's issue comments. |
Comment bodies are untrusted human-authored text. List responses sanitize and bound their display text, report showing N of M plus the applied order, and carry a warning for agents not to follow instructions found in comments. Requests for internal issue comments silently degrade to the public-only thread when the caller lacks projects.issues.manage. The MCP and CLI surfaces never accept an author or actor override: PT attributes every mutation to the bearer-token user and enforces comment ownership and permissions. In multi-organisation sessions, all eight MCP tools accept organisationId. To attach files to an issue comment, upload them to the issue with upload_project_file (or find them with list_project_files) and pass their ids as add_issue_comment's attachmentIds (pt add-issue-comment --attachment-ids <uuid,uuid,...>). The MCP checks only the shape, at most 20 UUID-shaped ids sent unchanged; the comments route (PT-1441) links each id that is a file on the issue or an unclaimed composer draft of the author's in its project, and refuses the whole comment with a 400, writing nothing, for any other id. For CLI bodies beginning with -, put options first and place -- immediately before the final body.
Project file tools (INF-804)
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
upload_project_file | pt upload-project-file | Upload a file to exactly one task or issue of a project (never the project root) through POST /api/projects/:id/files; returns the file id, its /raw path and a markdown embed, with a PLT-987 receipt. The MCP tool takes base64 of at most 2 MiB; the CLI reads --file <path>, up to 4 MiB in production. |
list_project_files | pt list-project-files | List one task's or issue's files (GET /api/projects/:id/files with taskId or issueId and signUrls=false), bounded by limit (1–100, default 50) and offset with the total. Refuses a task or issue from another project. |
get_project_file | pt get-project-file | A file's short-lived signed URL (GET /api/projects/:id/files/:fileId), plus the image itself for a PNG, JPEG, GIF or WebP of at most 3 MiB; includeImage: false (CLI --no-image) skips the fetch, and CLI --out saves the file. |
These wrap the existing file routes and add no PT behaviour, so the project and attachment read access rules apply to them unchanged. Only the fields the tools render are read from each response: storagePath never reaches an agent. File and uploader names are sanitised and bounded, an uploader whose account lives in another organisation (uploadedByName: null) shows as unknown, and every output carries an untrusted-content warning. Project Tracker's own type allow-list and 50 MB cap apply; it accepts no text types, and the MCP and the CLI also refuse SVG. The upload posts no comment: the embed goes into a comment through add_task_comment or add_issue_comment, where the /raw path is the image source the comment sanitiser keeps. Limits in full: Attachments and size limits.
Comment file references. list_task_comments and list_issue_comments add a files: <id>, … flag to each comment whose raw body embeds or links a file of its own project, read from <img src> and <a href> in an HTML body, or from complete markdown links and images and used reference definitions in a markdown body; HTML comments, markdown code and raw-text content are set aside before the body is judged to be HTML. For an issue comment the project is the issue's own. Ids are validated and lower-cased, at most 20 per comment (+N more past that), and a deleted comment carries none; --json emits them as fileRefs and fileRefsOmitted. Open one with get_project_file.
Time-entry tools (INF-470)
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
log_time | pt log-time | Log time against a task, or unlinked. Accepts durationMinutes instead of an instant pair. |
list_time_entries | pt list-time-entries | List the caller's own entries, newest-first; from / to / projectId filters. PT caps the read at 500. |
get_task_time | pt get-task-time | Logged / estimated / remaining hours for a task, plus the per-person breakdown across everyone who logged. |
update_time_entry | pt update-time-entry | Correct one of the caller's own entries; partial, with explicit nulls to clear a note or unlink a task. |
delete_time_entry | pt delete-time-entry | Permanently delete one of the caller's own entries — a hard delete with no tombstone. |
These wrap the existing /api/time-entries* routes and add no PT behaviour. Because an agent knows how long work took rather than when it began, log_time accepts durationMinutes and derives the window PT requires: alone it ends now, with startTime it runs forward, with endTime it runs backward, and an explicit startTime + endTime pair is passed through. All three fields together is rejected as over-specified — the duration would be either redundant or contradictory, and PT resolves a contradiction by silently honouring two of the three — as is a lone startTime or endTime. Every such rejection happens before any project or task lookup, so an argument error is never reported as a name-resolution failure.
Two asymmetries are deliberate. update_time_entry refuses a bare durationMinutes: on log_time a lone duration ends the window now, but on an update it would silently relocate an existing entry to today, and PT exposes no single-entry GET from which the original anchor could be recovered — so an explicit startTime or endTime is required. And because TimeEntry.endTime is nullable while GET /api/time-entries (unlike the by-task aggregate) filters neither null nor backwards windows, list_time_entries renders those rows as still open / invalid window and excludes them from its total, stating how many it could not measure, rather than failing the whole read on one row.
Attribution is not overridable. POST /api/time-entries takes the author from the session, so an entry is always the caller's own and there is no on-behalf-of form; list_time_entries is scoped to the caller's rows by PT (use get_task_time for a task's whole team), and PT scopes the two correction tools to the caller's own entries. Under a read-only OAuth scope only list_time_entries and get_task_time are available; the three mutators require write / admin. All five accept organisationId in multi-organisation sessions.
Task keys resolve across the organisation (INF-600). log_time, get_task_time and update_time_entry — like list_epic_children — accept a task key without its project: the key is resolved across every project of the organisation you are acting in, because the PT routes behind them take a task id and no project. A projectId (or the CLI's leading <project> / --project) is still accepted but is only a fallback: it is consulted when the key resolver cannot find the key on its own — a legacy or backup-imported key with a non-standard prefix or casing — and never for an ordinary key, so naming the wrong project cannot break a call the key alone satisfies. The one exception is a key typed in lower case that resolves to a task in a different project from the one named: the named project is checked for that exact spelling (a backup import can hold a lower-case twin), and if it cannot be checked the call is refused with advice to pass the task's UUID. A key that cannot be resolved fails with one location-free not-found message.
Platform feedback (PT-1267)
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
draft_platform_feedback | pt draft-platform-feedback | Stage a structured trial report (step, expected, observed, environment) and get its review link back. |
Project Tracker has two feedback channels, one per audience:
- The in-app "Send feedback" button (the
+ Createmenu and the/feedbackpage) reaches the platform team that operates the installation, from every workspace except the platform tenant itself. That workspace is the platform team's own, so once Project Tracker has identified it as the platform tenant, neither entry point is shown there. Both are also hidden while Project Tracker looks up whether the active workspace is the platform tenant (GET /api/feedback/destinations), so they never flash in; if that lookup fails they are shown, in any workspace, and the server still refuses the platform tenant's submission. A submission addressed from it to itself —POST /api/feedbackwithdestination: "platform", staging a trial-report draft, or sending one — answers 400 before anything is written in either tenant: no audit entry, no item, no draft (PT-1459). Its/supportportal, andPOST /api/feedbackwithout adestination, still write into its own queue. Everywhere else the button posts withdestination: "platform", and the item is written into the platform tenant's queue — not the submitter's. The response is an acknowledgement only: no id or key of the delivered item comes back. Attachments are not carried, and a credential-shaped string in the title or description is refused. The sending workspace keeps its own copy of what it sent (words it wrote, plus a status): its/feedbacklist includes those items, marked Sent to Constellation, with the status the platform team has set. They open a read-only detail and cannot be edited, promoted or prioritised from the workspace (GET /api/feedback/sent, a read of the workspace's own data only). When the platform team changes an item's status — by hand, by promoting it to an issue or a task, or through the GitHub issue sync — the change reaches the sender's copy through the event outbox: every status change publishesprojects.feedback.status_changedin the same transaction, and a handler writes the item's current status into the copy, retrying a delivery that fails. Each change increments the item's status revision, and the copy only ever moves to a higher revision, so a retried or late delivery never brings back an older status. Items copied by the history import have no sender's copy and are not updated. - The Support Portal (
/support) stays each workspace's help desk for its own customers. Its "Report a problem" and "Share feedback" keep writing into that workspace, unchanged.POST /api/feedbackwithout adestinationbehaves exactly as before.
A promoted feedback follows its task or issue (PT-1457). Once a feedback is promoted, the task or issue it became decides its status, in the same transaction as the work item's own change, and each change emits projects.feedback.status_changed. A task that becomes complete — a completed status, a completion date, or progress at 100 — moves the feedback to Done, and reopening it (or lowering its progress) moves it back to Promoted. An issue resolved as FIXED moves it to Done, one resolved as WONT_FIX or DUPLICATE (a merged-away duplicate included) to Dismissed, and a reopen back to Promoted; closing an issue nobody resolved, a DEFERRED resolution and an organisation's own resolution outcomes leave the feedback as it is. This holds for every way the work item changes: the task page, the board, bulk edits and moves, approvals, an Excel re-import, a GitHub pull request, a parent task completed by its children, and the issue actions, bulk actions, merges and replies. A promoted item keeps its link to the task or issue whatever its status, shows its status read-only, and cannot be promoted again.
The platform tenant is named by the server-only PLATFORM_FEEDBACK_TENANT_ID variable (see the deployment page). When it is unset or malformed, the button says feedback to the platform team is not set up and the API answers 503; when it names a tenant that is not active, the API answers 503 when the person submits. It never falls back to another tenant. The write enters the destination through the normal attested tenant door and is recorded with auditCritical() on both sides: in the platform tenant under a system actor, in the source workspace under the submitting user. A delivered item stores the originating workspace and its name, never the submitter's identity. In the platform tenant those items are visible only to holders of feedback.manage, who can filter the /feedback list by origin; they never appear in global search or duplicate suggestions.
Importing the button's history. Before PT-1267 the button wrote into the submitter's own workspace. npm run db:backfill-platform-feedback -w @constellation/project-tracker -- --tenant <uuid> … copies a named workspace's button feedback (source = WIDGET; portal feedback stays put) into the platform queue, with its origin and no author, the original's status (PROMOTED becomes PLANNED, since its target is in the source workspace) and creation date. It names every workspace explicitly and never scans across tenants, runs on DATABASE_URL with RLS enforced, skips and reports credential-shaped rows, and records every crossing with auditCritical() in the workspace it touches — each read, each copy and each workspace's outcome, dry run included. A re-run copies nothing twice. Run the :dry-run wrapper first; the applying one needs CONFIRM_APPLY=1 and PLATFORM_FEEDBACK_TENANT_ID. The import leaves each original in its workspace; the retirement pass below finishes the move.
Retiring the imported originals (PT-1458). Once the build that hides and guards a retired original is deployed, npm run db:retire-platform-feedback-originals -w @constellation/project-tracker -- --tenant <uuid> … gives each named workspace a "Sent to Constellation" copy of every item the import sent from it, and retires the original. For each platform row the import made that has no copy yet, it writes the copy into the workspace (the original's type, title, description and date, and the platform row's current status), stamps the original as retired, and then links the platform row to the copy, so every later status change the platform team makes reaches it. A retired original is never deleted: it is hidden from the workspace's feedback list, its permalink, search, duplicate suggestions and attachments, promote and edit refuse it with 409, POST /api/feedback/<id>/sync-github does not find it (404), and a GitHub status update for it is acknowledged without being applied. An original that was already promoted in its workspace — status PROMOTED, or a task or issue there names it — is not retired: the pass reports its key and its target's key for manual reconciliation and leaves both where they are. Credential-shaped originals, originals that no longer exist and workspaces that turned platform feedback off are skipped and reported too. The pass follows the import's rules (named workspaces only, DATABASE_URL with RLS enforced, auditCritical() in every workspace it touches, dry run included) and a re-run changes nothing. Run db:retire-platform-feedback-originals:dry-run first; the applying wrapper needs CONFIRM_APPLY=1 and PLATFORM_FEEDBACK_TENANT_ID.
Agent trial reports. At the end of an onboarding trial, an agent can stage a report with draft_platform_feedback. Staging writes only a draft in the caller's own workspace and returns a review link (/feedback/platform-drafts/<id>?org=<organisation id>), naming the organisation it was staged in. A person who opens the link while working in another organisation is offered an explicit switch to the draft's organisation, if they belong to it; the page never switches by itself, and a non-member sees only "Draft not found". Nothing leaves the workspace until a person opens that link and clicks send. That step accepts only a browser session: an agent principal, an API key, an MCP OAuth token or a pt login token is refused, so an agent cannot approve its own report. Every field is checked when staged and again when sent. A credential, an email address, source code or a home-directory path is rejected with a 400 naming the field, never silently redacted, so the agent knows exactly what to rewrite. A draft is delivered at most once, and a draft being sent cannot be discarded.
Unreviewed drafts expire (PT-1297). A draft nobody sends within 14 days of being staged expires. Its review page then says it expired and nothing was sent, sending it or discarding it answers 409, and the API reports its status as EXPIRED. Staging a draft schedules its expiry in the same transaction; when the 14 days have passed, the notifications cron records the draft as EXPIRED in its own workspace with an auditCritical() entry (platform_feedback.draft.expired, a system actor), unless a person sent or discarded it first. A draft staged before this change is reported EXPIRED once it is 14 days old, without a recorded transition. A draft whose send is in progress does not expire while it stays "being sent": a later send still settles it. Once the 14 days have passed, such a draft is never sent anew: a send that fails past the 14 days keeps it "being sent", and the next send, five minutes on, only checks whether it reached the platform team. If it did, the draft is recorded as sent; if not, it expires, with the same audit entry.
Finding a draft again (PT-1561). The Feedback page lists the workspace's drafts still waiting to be sent, each with a link to its review page and the date it expires, so a lost review link does not lose the draft. A draft is listed to its author and to members with feedback.manage, the same people who can open it. Expired, submitted and discarded drafts, and a draft whose send is in progress, are not listed. The list is empty, and not shown, in a workspace with no pending draft.
Rate limits (PT-1295). Sending to the platform team from the button is limited to 20 an hour per person and 60 an hour per workspace; staging agent drafts has the same two limits, counted separately. Both are counted in the database, in the workspace's own row-level-secured projects.rate_limit_buckets, so they hold however many server instances answer the requests. A refused request answers 429 with Retry-After giving the seconds until the hour that refused it ends. The hour is a fixed window that starts at the first request after the previous one ended, so a burst straddling the boundary can admit up to twice a limit. A refused request is counted too, so retrying inside the window does not get through, and it does not push the window's end later. A request the per-person limit refuses is not counted against the workspace; one the workspace limit refuses still counts against the person.
The same database-backed limiter, with the same fixed hour, now also holds three per-person limits (PT-1563): support tickets from the portal (POST /api/support/tickets, 10 an hour), feedback attachment uploads (POST /api/feedback/attachments, 60 an hour) and rich-text editor uploads made before the entity exists (POST /api/files/pending, 60 an hour). Each answers 429 with Retry-After when refused. Project Tracker keeps no in-memory rate limiter any more.
Turning it off for a workspace. A workspace's administrators can turn feedback to the platform team off: Settings → Organisation → Feedback to Constellation (PUT /api/platform-feedback/settings, which needs org.settings.manage; every member can read it with GET). It is on until someone turns it off. While it is off, nothing is sent from that workspace to the platform team: the button says the workspace's administrators have turned it off, and the button's submissions, draft staging and the sending of drafts staged earlier all answer 403 PLATFORM_FEEDBACK_DISABLED, writing nothing in the workspace, with the one exception for a draft already delivered described at the end of this paragraph. The history import skips the workspace too, and reads none of its feedback. It covers every member, guests included; a guest follows the switch of the workspace they are acting in. Discarding a draft stays possible, and status changes the platform team makes still reach the workspace's copies of what it sent earlier, since those flow into the workspace rather than out of it. Each change is recorded with auditCritical() under the administrator who made it. A draft whose earlier send was cut off after delivering is still recorded as sent while the switch is off: once the send has been stuck for five minutes, sending it again checks whether it reached the platform team and, if it did, records it as sent without delivering it again. A draft that never reached the platform team is refused like any other; the check itself is recorded on the platform team's side, as every such check is.
Other tools
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
get_workspace_context | pt workspace | Orientation: current user, acting-org initiatives + projects, top IN_PROGRESS tasks. |
consult_coordinator | pt consult | Ask the hosted coordinator brain an initiative-wide question. See below. |
list_stages | pt list-stages | List stage-gate pipeline stages for a project. |
How a coordinator consult answers (PLT-669)
A consult no longer reasons only over a pre-loaded snapshot. When the caller is
entitled to it, the brain runs a bounded tool loop, querying Project Tracker live
— tasks, issues, the initiative summary, the decision log, and wiki pages — before
answering. This applies to both callers of the same route: consult_coordinator /
pt consult (JSON) and the dashboard chat (SSE).
What the loop can read is narrower than what the brain is asked about:
- Entitlement. The loop needs full
tasks.readplus an active membership — the same gateGET /api/tasksapplies, because the tools enumerate work across projects. A caller without it still gets an answer; it is reasoned from the initiative snapshot alone, exactly as before. - Project scope. Task and issue reads are narrowed to the projects the caller can
read, matching
GET /api/initiatives/:id/work-items. A referenced epic or blocking task in a project the caller cannot read is omitted rather than named. - Initiative scope. The summary resolves only the initiative the consult is authorised for; naming another one returns nothing.
- Classification. The decision log returns consults on
UNCLASSIFIEDinitiatives only, so classified discussion is never replayed into an answer. - Wiki reads are performed as the calling user, so the wiki's own permissions apply.
Because the loop makes several model calls, a consult can take appreciably longer than the previous single-shot answer, and a consult that exceeds its budget returns a typed error with the partial reasoning recorded rather than a bare timeout.
When a consult runs out of budget (PLT-849)
The loop is bounded by a time budget and by resource caps, and the two are reported differently because they mean different things to a caller:
| Status | Code | Meaning |
|---|---|---|
504 | COORDINATOR_DEADLINE_EXCEEDED | the model phase ran out of wall-clock time |
429 | TOO_MANY_REQUESTS | a per-run tool-call or token cap was reached — not rate limiting |
504 with no code | — | a platform or proxy timeout, not the coordinator's own |
The wall-clock budget covers the whole model phase — the tool loop and its one synthesis retry draw on one shared budget, the retry taking whatever the loop left rather than starting a fresh timer of its own.
The caps bound the tool phase, not the answer (PLT-909). Every turn re-sends
the whole conversation, so the cumulative token cap is a function of step count
rather than initiative size — and a cap that fires on the answer turn only throws
the answer away. The loop therefore holds back a synthesis reserve: tools are
withdrawn one turn before the next tool turn would exhaust tokens (projected from
the last turn's actual usage), tool calls (when fewer remain than the last turn
fanned out) or the wall clock (45 s of the 120 s), and the model is told to answer
from what it has gathered and to say what it could not verify. A turn that asks
for more tool calls than remain executes the ones that fit and gets an explicit
refusal for the rest; a repeated identical call is answered with a pointer to its
earlier result (and still counts toward the cap — the model spent it); and a turn with no tool calls — the answer — is never aborted by
the tool-call or token cap, whatever it costs. So the 429 above now means the
loop spent a cap on a turn that was still asking for tools, which the reserve
makes rare; maxToolCalls is the number of calls the loop may execute, and
reaching it ends tool use rather than the consult. The persisted trace records
which cap ended the tool phase (synthesisForced) and any refused calls
(refusedToolCalls).
A synthesis turn spends its output on the answer (PLT-1239). On a model that
thinks by default (claude-sonnet-5, claude-opus-5) the per-turn output
ceiling covers thinking and answer, so a forced synthesis could think until the
ceiling and return no text — production consult e11b3ca3 did exactly that. The
forced synthesis turn and the synthesis retry therefore run with thinking turned
off; tool turns, and an answer the model reaches while tools are still offered,
keep adaptive thinking. A retry is started only when the time left fits one at
the throughput measured in the same run, and each persisted step records
reasoningBlocks, the number of thinking blocks the turn produced.
Single-shot completions spend their output on the answer too (PLT-1241). The
same ceiling applies outside the tool loop. A consult that runs without a toolset
(over JSON or SSE), a cycle retrospective and a groundedness grading call each
make one completion whose ceiling was sized for a text answer — 4 096 tokens, or
500 for the grader — so on those models every one of them, retries included,
runs with thinking turned off. If a completion still stops at its ceiling after
thinking (a model that thinks by default but cannot be told not to), the failure
is recorded as model output ceiling spent on reasoning — in the consult row's
error, in the log and in the grader's job error — rather than as an unparseable
response.
On both the 429 and the coded 504, the consult row and its audit entry are
written before the error is raised, so the run is in the decision log with the
partial trace attached. A code-less 504 carries no such guarantee: it means the
function was killed from outside and may have been stopped part-way.
See also
- Tasks page — unified work surface at
/projects/tasks(replaces the old/my-taskspage). Scope toggle, multi-view, inline editing, bulk actions. - Work hierarchy & links — epics, sub-tasks, cross-project initiatives, and typed links.
- Labels & goal views — cross-project grouping by label.
- Flow engine — ready set and review queue for the agent fleet.
- In-product assignable agents — execute work on assignment / @mention and report back in-thread (allowlist-gated).
- Advanced features & configuration — resolution outcomes, gate criteria, dashboard layouts, KB space refs, multi-org header.
- Milestones — payment-linked milestone tracking.
- 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.