Skip to main content

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 uses INF-*, 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_id scoping — 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 the app.tenant_id Postgres session variable, which authentication attaches to the request (via withTenantContext / set_config, not SET LOCAL — see §9). Since the INF-60 cutover (production has run on a non-BYPASSRLS role 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-owned SECURITY DEFINER functions (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 id plus 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​

AggregatePurpose
initiativesTop-level container (e.g. Constellation Platform Development). Supersedes the legacy "programme" term (PLT-165).
projectsUnits of delivery inside an initiative. Per-project custom statuses, fields, stages, gates.
stagesOrdered phases of a project's stage-gate pipeline. Each stage may have a gate.
gatesCriteria-bundle that must pass for a stage to be marked COMPLETED.
tasksPlanned work with acceptance criteria. Auto-transition to DONE on PR-merge via webhook.
issuesUnplanned work — bugs, incidents, helpdesk. Has severity, optional sla_policy, assigneeId, transition state machine.
time_entriesTime-tracking entries on tasks.
feedbackLightweight quick-feedback intake; promotes into a task or issue.
platform_feedback_draftsAgent-staged trial reports awaiting a person's review before they go to the platform team (PT-1267).
invitationsOut-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 Open and In progress pills 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 (the pt list-tasks subcommand calls this).
  • POST /api/issues/:id/transition — move an issue between OPEN / IN_PROGRESS / CLOSED / REOPENED.
  • POST /api/webhooks/github — auto-transition tasks to DONE when 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), with projects.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}/disable and /enable, POST /api/github/installations/{id}/refresh — the GitHub integrations settings (PT-1354), with projects.github_integrations.manage through 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 from coordinator.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 the consults_tenant_read RLS 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 holding coordinator.use through a GLOBAL-scope assignment. Backs the /projects/coordinator picker, the workspace's initiative switcher and its access check. It requires no initiatives.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:

StatusBodyWhen
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.
403the platform error envelopeThe x-act-as-org selector names an organisation the caller is not an ACTIVE member of.
500the platform error envelopeThe 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.

RoutePurpose
GET /api/coordinator/threads/settingsThe tenant's mode, { "data": { "mode": "off" | "read-only" | "on" } }. Answers in every mode.
GET /api/coordinator/threads?scope&status&after&limitThe caller's conversations (scope: mine or shared; status: active or archived), each annotated with its initiative.
GET /api/coordinator/threads?taskId&after&limitThe conversations linked to a task the caller can read. A missing and an unreadable task answer the same 404.
POST /api/coordinator/threadsCreate { 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&limitSearch 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&limitOne page of history, newest first (50 by default, 100 at most).
GET / POST /api/coordinator/threads/{threadId}/participantsList 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}/turnsSend { 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}/retryA new attempt of a failed or interrupted newest turn; the server reuses the stored question.
GET /api/coordinator/threads/{threadId}/turns/{turnId}/actionsThe 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 turn frame 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.read opens 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-scoped projects.read.own adds 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. So tasks.update.any from 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 and GET /api/time-entries/by-task/:taskId (in the task's project), the issue-move target, sample-scenario removal (projects.delete in 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 needs tasks.create in each selected project, task eligible-cycles needs tasks.update.any in 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 neither projects.read nor projects.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.manage or initiatives.create held 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-scoped projects.read, so a member working on one project's issues can load the values its forms need. GET /api/wiki-spaces likewise 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 with search=, a single status= or sort= 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.read in the project, and a file attached to an INTERNAL issue comment needs projects.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 } or issue: { 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.

UploadOwnerWho may upload
POST /api/initiatives/:id/filesthe initiativeaccess to it, initiatives.read, one of files.upload / comments.create / initiatives.update, and the clearance
POST /api/cycles/:cycleId/filesthe cyclethe cycle's own scope arm's read access, and one of files.upload / tasks.update.any
POST /api/files/pendingnone until a createan 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's classification (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.read in it; initiative cycles the initiative's access, full-scope initiatives.read and its clearance; project-set and organisation cycles an internal member with organisation-wide projects.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/initiatives and every cycle create accept attachmentIds (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 with 400. 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:

RouteOwning tenant atMaps to
GET /api/projects/:id/tasks/:taskIddata.project.organisationIdprojects.tenant_id
GET /api/issues/:issueIddata.organisationIdissues.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.

Reporterreporterorganisation / reporterOrgName
Homed in the issue's tenantfull: name, display name, email, avatar, locale, time zone, PT roletheir ACTIVE-membership organisation in that tenant
Homed in another tenant, related to the issue's tenantdisplay fields only: name, displayName, avatarUrl; email, preferredLocale, preferredTimeZone, ptRole are nullnull
No relationship with the issue's tenantname: "Unknown", every other field nullnull

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.

Fixed behaviour — the listing previously returned nothing

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 assigneeId was rejected with "No user with id … exists in your active tenant", for every caller, in their own organisation — while find_user happily 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, whose findByOrg filters on organisation_id and 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_memberships row 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-scope projects.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, a projects.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/people window they already see, so find_user can 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)​

Breaking response change

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 ACTIVE are 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] into x-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:

statusMeaning
okThe knowledge base exists.
skippedSetup was not attempted: no wiki is configured (wiki_not_configured), or the request ran out of time (no_time_left).
failedSetup 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:

SectionRequired permission
Organisation profile, subscription & usageorg.settings.manage
Membersorg.settings.manage to see it, org.members.manage for its data
Rolesorg.roles.manage
Invitationsorg.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. InvitationManager evaluates its PermissionGate only after issuing its reads and rendering a loading card, so the page mounts it solely for org.invitations.manage holders 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/permissions can 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 in SPEC-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.

BranchRequired permissionAdditional check
Project-scoped (projectId present)projects.collaborators.manage or org.invitations.manageCaller 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.

The project-access check is org-wide, not "your own projects"

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 the Guest Customer role 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 canonical Guest 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 answers 404.

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):

FieldMeaning
pendingInvitationsPENDING project invitations whose email matches no collaborator's email, each with expired: true once past expiresAt (PT-1285). The token is never returned.
data[].hasOpenInvitationtrue when a redeemable invitation names this collaborator, whatever the row's status. Never set by an expired invitation.
data[].openInvitationIdThe id of that invitation, or null — what the card revokes or resends (PT-1210).
data[].expiredInvitationIdThe id of a PENDING invitation past its expiry that names this collaborator, or null — resendable and revocable, but it locks nothing (PT-1285).
data[].revokeRemovesRowtrue 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" }.

EndpointEffect
DELETE /api/projects/{id}/invitations/{invitationId}The invitation becomes EXPIRED and its link stops working. Audited (invitation.revoked).
POST /api/projects/{id}/invitations/{invitationId}/resendThe 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.

RouteWhat it does
GET /api/sample-scenariosOne 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.create and tasks.create. Removal needs projects.delete and initiatives.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. wikiResidual is true while 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 same DELETE with residualOnly: 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 (sampleScenarioResourceId in @constellation/contracts), so a second install answers already_installed and a second removal not_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; a multi-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.removed critical audit entry per operation, one routine CREATE entry per task, and one projects.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 requires initiatives.read in 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 (resource auth_identity) for each provider identity Directory's link ledger (identity.record_provider_link) had not recorded, from whichever zone. The account's own email identity is never recorded.
  • AUTH_LOGIN (resource session), naming the provider and that identity's creation time in changes.

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_RESOLVED gate criterion (PT-48), knowledge-base space references (PT-374), and the multi-org x-act-as-org header (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, projectId and date (written only for a day other than today). An old ?view=list link 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:

  1. verifies X-Hub-Signature-256 against GITHUB_APP_WEBHOOK_SECRET, or GITHUB_APP_WEBHOOK_SECRET_NEXT during a rotation, before reading anything — an unverified delivery, or any delivery while no App secret is set, is 401;
  2. resolves the delivering installation's tenant through projects.resolve_github_installation (ADR-071), called from src/lib/github-app-delivery.ts only — npm run check:github-resolver-call-site (pre-push gate 35) fails the build on any other call site;
  3. 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:

  1. POST /api/github/connect-requests with { "projectId": "<uuid>" } or { "orgWide": true } answers 201 with a redirectUrl: GitHub's install page for the App. For an installation the organisation already holds, installationId skips straight to GitHub's authorization page (to add repositories, or re-map a removed one).
  2. GitHub returns the browser to the App's Setup URL, GET /api/github/app/setup (https://constellation.planetb2b.com/projects/api/github/app/setup in 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.
  3. 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 ACTIVE integration and re-enable a DISABLED one while its installation is CLAIMED. 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 ACTIVE shows its status; a rule whose project the admin cannot read keeps it as Project not accessible and 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.

SettingValue
Repository permissionsMetadata: Read, Pull requests: Read, Issues: Read and write
Organization permissionsMembers: Read (the connect flow's admin check)
Subscribed eventsPull request, Issues, Repository. installation and installation_repositories reach every App without a subscription.
Webhook URLhttps://<host>/projects/api/webhooks/github/app (production: https://constellation.planetb2b.com/projects/api/webhooks/github/app). Webhook active, content type application/json.
Webhook secretthe value of GITHUB_APP_WEBHOOK_SECRET
Setup URL and Callback URLSetup 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 installedAny 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 of https://github.com/apps/<slug>. Without it every connect-flow leg answers 503;
  • 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_NEXT to 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 into GITHUB_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_NEXT and redeploy: a token request GitHub refuses with the current key is retried once with the next one. Move it into GITHUB_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).

CommandActs onTransition
dispute-bindingbindingany state but RELEASED → DISPUTED. The binding stops serving content events; GitHub's lifecycle events keep moving the state the dispute closes to.
close-binding-disputebindingDISPUTED → the state it closes to: the one it was disputed from, as GitHub has moved it since
release-bindingbindingDISPUTED or DELETED → RELEASED, freeing the installation id. Refused while any integration of the binding is not RELEASED: release each first.
dispute-integrationintegrationany state but RELEASED → DISPUTED
close-integration-disputeintegrationDISPUTED → 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-integrationintegrationDISPUTED 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-integrationintegrationACTIVE → DISABLED
park-integrationintegrationthe 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-integrationintegrationthe 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:

  1. 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 with 401, so run the next step straight away.
  2. 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. An issues delivery acknowledged before both are done is never redelivered.
  3. Only then deactivate (do not delete) the repository's legacy webhook (Settings → Webhooks).
  4. 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_SECRET stays 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​

LayerPathMay import from
API Routessrc/app/api/Tools only
Toolssrc/server/tools/Services, Policies, Events
Servicessrc/server/services/Repositories, Policies, @constellation-platform/db, @constellation-platform/audit
Repositoriessrc/server/repositories/@constellation-platform/db (Prisma for projects.*, raw SQL for identity.*)
Policiesaccess-control helpers@constellation-platform/auth-core
Workflowssrc/server/workflows/Tools or Services (never repositories)
Eventssrc/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​

9. Known exceptions / pitfalls​

  • Auth redirect destinations are internal paths. /auth/confirm accepts a next path beginning with a single /, with no //, @, backslash or colon, that parses to the request origin. /auth/callback uses 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 /dashboard for 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 redirect parameter, and the next a 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 shared signIn resolves 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 /projects prefix. 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 at https://constellation.planetb2b.com/projects/api/webhooks/github. Local dev runs at / because NEXT_PUBLIC_BASE_PATH is unset. See Deployment.
  • fetch() does not get basePath auto-prepended. Use apiUrl() from apps/project-tracker/src/lib/api-url.ts for any client-side fetch(). <Link> and router.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_RE is /\[([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 plain INF-30 when 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_link and moves the task into review. The merge now also completes a branch-named task when its pr_link is 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 500 with the keys in failed. The delivery then shows as failed under Settings → Webhooks → Recent deliveries. A race is still a skipped no-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 the failed keys by hand.
  • Identity reads are raw SQL; identity writes go through Directory. Don't try to add Prisma relations from projects.* to identity.* — they are in different schemas. Use IdentityUserRepo / IdentityPermissionRepo to read. To mutate identity, call one of the Directory-owned SECURITY DEFINER functions (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-writes fails 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 zod and use the v4 surface (object-form z.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 LOCAL in raw SQL. A bare SET LOCAL outside 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) run SELECT set_config('app.tenant_id', $1, true) inside the interactive transaction on the pooled DATABASE_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. The CRON_SECRET-gated cron routes connect on PROJECTS_CRON_DATABASE_URL instead, the confined cron login (ADR-070).
  • Open a tool's transaction with runInTenantTransaction, not a bare $transaction that 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 your tx, and even after you set app.tenant_id on tx yourself. Raw statements stay on tx. So the model writes commit apart from the raw audit or outbox insert. A model write that touches a row tx has locked raw also waits on that lock until the transaction times out: milestone PATCH, issue move and tag deletion all did. runInTenantTransaction sets the GUC and enters the span. A structural test (TOOLS_BARE_TRANSACTION_SITES in transaction-inventory.test.ts) fails on a bare call in src/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 against task_links:
    • task_links.project_id is 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 BLOCKS predecessor 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. loadBlocksEdgesForTasks does 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 BLOCKS edge 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. Prefer getCachedUser() ?? await getCurrentUser().
  • The pt CLI 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 toolpt CLI subcommandPurpose
list_initiativespt list-initiativesList all initiatives with project counts and progress.
get_initiative_summarypt get-initiative-summaryGet one initiative with full project list. Use at session start for orientation. Accepts UUID or name for initiativeId.
create_initiativept create-initiativeCreate a new initiative (always ACTIVE, no project links). Required: name, startDate. Optional: description, endDate.
update_initiativept update-initiativeUpdate an initiative's name, description, status, dates, or knowledgeBaseSpaceIds (wiki KB space UUIDs, max 10). Accepts UUID or name.
delete_initiativept delete-initiativeDelete an initiative. Cascades — removes the record and unlinks projects (projects are NOT deleted).

Breaking change (PLT-165): the old MCP tools list_programmes and get_programme_summary and the pt programme command are removed. Use the _initiative equivalents. The programmeId parameter on get_programme_summary is renamed to initiativeId.

Project tools​

MCP toolpt CLI subcommandPurpose
list_projectspt list-projectsList all projects (status, progress, task count).
find_projectpt find-projectResolve 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_overviewpt get-project-overviewProject 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_projectpt create-projectCreate a project. Required: name, startDate. Optional: description, endDate, prefix, initiativeId (UUID or name).
update_projectpt update-projectUpdate a project's settings. Optional: name, description, status, startDate, endDate, prefix, issuePrefix, initiativeId, knowledgeBaseSpaceIds (wiki KB space UUIDs, max 10).
update_project_statuspt update-statusEdit 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_projectpt delete-projectDelete 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 toolpt CLI subcommandPurpose
list_taskspt list-tasksList 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_taskspt searchTenant-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_childrenpt list-epic-childrenList 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_stepspt list-epic-stepsList 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_steppt add-epic-step / pt update-epic-stepAppend or edit a step; "finished when" is required. Needs tasks.update.any in the epic's project.
archive_epic_steppt archive-epic-stepArchive a step (never deleted); its tickets are detached.
reorder_epic_stepspt reorder-epic-stepsPut the active steps in a new order — the list must name every active step once.
list_cycle_stepspt list-cycle-stepsThe 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_milestonespt list-initiative-milestonesAn 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_milestonept create-initiative-milestone / pt update-initiative-milestone / pt archive-initiative-milestoneCreate, 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_steppt pick-cycle-step / pt unpick-cycle-stepPick 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_taskpt get-taskGet 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_taskpt create-taskCreate 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_taskpt update-taskUpdate 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_goalpt accept-epic-goalThe 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_taskpt claim-next-readyAtomically claim the top ready task in a project (projectId + assigneeEmail). Race-free; returns nothing to claim at the agent WIP cap.

Issue tools​

MCP toolpt CLI subcommandPurpose
list_issuespt list-issuesList issues (bugs, incidents, support tickets).
get_issuept get-issueGet full issue details.
create_issuept create-issueCreate a bug/incident/support ticket.
update_issuept update-issueUpdate issue title, description, or due date.
assign_issuept assign-issueAssign an issue to a user.
escalate_issuept escalate-issueChange issue severity.
transition_issuept transition-issueMove issue between statuses.
resolve_issuept resolve-issueResolve an issue with a reason.

Comment tools (INF-52)​

MCP toolpt CLI subcommandPurpose
list_task_commentspt list-task-commentsList a task's comments; newest-first by default, with configurable order and limit. Flags the files each comment embeds (files:).
add_task_commentpt add-task-commentAdd a task comment; notify project-visible users with MCP mentionUserIds or CLI --mention-user-ids.
update_task_commentpt update-task-commentEdit under PT's author-or-comments.update permission rules.
delete_task_commentpt delete-task-commentSoft-delete under PT's author-or-comments.delete permission rules.
list_issue_commentspt list-issue-commentsList issue comments; supports order, limit, and staff-only notes via includeInternal. Flags the files each comment embeds (files:).
add_issue_commentpt add-issue-commentAdd 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_commentpt update-issue-commentEdit one of the authenticated user's issue comments.
delete_issue_commentpt delete-issue-commentSoft-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 toolpt CLI subcommandPurpose
upload_project_filept upload-project-fileUpload 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_filespt list-project-filesList 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_filept get-project-fileA 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 toolpt CLI subcommandPurpose
log_timept log-timeLog time against a task, or unlinked. Accepts durationMinutes instead of an instant pair.
list_time_entriespt list-time-entriesList the caller's own entries, newest-first; from / to / projectId filters. PT caps the read at 500.
get_task_timept get-task-timeLogged / estimated / remaining hours for a task, plus the per-person breakdown across everyone who logged.
update_time_entrypt update-time-entryCorrect one of the caller's own entries; partial, with explicit nulls to clear a note or unlink a task.
delete_time_entrypt delete-time-entryPermanently 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 toolpt CLI subcommandPurpose
draft_platform_feedbackpt draft-platform-feedbackStage 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 + Create menu and the /feedback page) 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/feedback with destination: "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 /support portal, and POST /api/feedback without a destination, still write into its own queue. Everywhere else the button posts with destination: "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 /feedback list 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 publishes projects.feedback.status_changed in 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/feedback without a destination behaves 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 toolpt CLI subcommandPurpose
get_workspace_contextpt workspaceOrientation: current user, acting-org initiatives + projects, top IN_PROGRESS tasks.
consult_coordinatorpt consultAsk the hosted coordinator brain an initiative-wide question. See below.
list_stagespt list-stagesList 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.read plus an active membership — the same gate GET /api/tasks applies, 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 UNCLASSIFIED initiatives 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:

StatusCodeMeaning
504COORDINATOR_DEADLINE_EXCEEDEDthe model phase ran out of wall-clock time
429TOO_MANY_REQUESTSa 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​