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 run as the migration role rather than on a tenant request and are grandfathered, so new identity mutations belong in a Directory migration regardless of where they are triggered from.

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.
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.
  • 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/dashboard-layout / PUT /api/dashboard-layout — per-user, per-tenant home-dashboard widget ordering. See Advanced features & configuration § Dashboard layouts.

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.

Two properties of the listing are worth knowing, because the payload does not show them:

  • Membership is not required. The listing is keyed on identity.users.tenant_id, not on user_tenant_memberships, 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.
  • It is not filtered by status, so soft-deleted and suspended users still appear — unlike the ?email= lookup, which filters them. Tracked as PT-908.

A user whose identity row lives in another tenant but who holds an ACTIVE membership in the acting one is not listed, so their UUID is still rejected there. That divergence is tracked in INF-322.

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.

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

Read the scope precisely, because only that one guard is redeemable-only. The creation path keeps a second check that refuses any still-PENDING row, expired or not, and the org-level branch's duplicate check does the same. Both exist to stop the insert colliding with UNIQUE (email, project_id); narrowing them to redeemable rows would turn today's 409 into a unique-violation 500. So re-inviting someone whose invitation has expired but was never reaped still answers 409, and waiting for expiry does not make a retry succeed — recycling a stale invitation is a documented follow-up.

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

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

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 — can be installed on a design partner's own repo without a redeploy. Each configured repo maps to exactly one tenant and one project, with its own webhook secret, so a partner's PT-12 can never touch another tenant's PT-12. Full design — SPEC-pt-488-per-tenant-github-webhooks.

How resolution works. On each inbound event the route reads repository.full_name (normalised to lowercase — repo names are stored canonically lowercased), looks it up in projects.github_integrations (migration 064_github_integrations.sql) via the RLS-safe read-bypass transaction (runInRlsBypassReadTransaction), verifies the signature against that row's per-config secret, and scopes all task/issue key resolution to that row's tenant and mapped project. The three-way outcome: an enabled row verifies against its own secret; a disabled row is rejected outright (401 — it never falls back); no row at all falls back to the global GITHUB_WEBHOOK_SECRET + GITHUB_WEBHOOK_ORG_ID env pair only if the payload's repo matches GITHUB_REPO_OWNER/GITHUB_REPO_NAME, so the B2B-Online/constellation deployment keeps working unmigrated and a stray repo can never ride the fallback into the platform tenant.

Secrets are AES-256-GCM encrypted at rest under the GITHUB_INTEGRATION_ENCRYPTION_KEY env var (a base64-encoded 32-byte key, generated with openssl rand -base64 32; must also be declared in the root turbo.json build.env list or the build strips it). Rows carry a key_version so keys can be rotated incrementally. GitHub Apps were evaluated and deferred for the MVP in favour of the manual per-repo webhook; the config table's shape is designed so a future GitHub App can slot in without a second migration.

Onboarding runbook (no UI yet — one operator-script step):

  1. Generate a webhook secret into a shell variable (kept out of history; you paste the same value into GitHub in step 3):
    SECRET=$(openssl rand -hex 32)
  2. Encrypt + upsert the config row with the operator script (never hand-craft the ciphertext — a plaintext INSERT produces a value decryptSecret rejects). The script reads the same GITHUB_INTEGRATION_ENCRYPTION_KEY and DATABASE_URL/DIRECT_URL the deployed app uses, and upserts keyed on the lowercased repo_full_name (re-running it rotates the secret in place). It validates that --project belongs to --org, and refuses to move an already-mapped repo to a different tenant unless --reassign-tenant is passed explicitly:
    # Pipe the secret — running without a pipe prompts on stdin, but the input is NOT masked
    printf '%s\n' "$SECRET" | npx tsx apps/project-tracker/scripts/github-integration-secret.ts \
    --org <partner-org-uuid> --project <partner-project-uuid> \
    --repo partner-org/their-repo
  3. Configure the GitHub webhook on the partner's repo → Settings → Webhooks → Add webhook:
    • Payload URL: https://constellation.planetb2b.com/projects/api/webhooks/github (the same shared endpoint; the /projects prefix is the multi-zone basePath).
    • Content type: application/json.
    • Secret: the plaintext secret from step 1 (echo "$SECRET" to display it).
    • Events: Pull requests only. Partner integrations are PR-only for now — the issues → Feedback sync is repo-local (not yet tenant-scoped), so issues events from a configured repo are intentionally ignored (they never touch another tenant's Feedback rows). Selecting Issues in GitHub is harmless (those deliveries are skipped) but pointless until repo-scoped issue sync ships.
  4. Verify by opening a test PR in the partner's repo whose title references a real task key in the mapped project (e.g. feat: thing [ABC-1]) and confirming the task transitions to the project's review status with pr_link written.

To pause an integration without deleting it, set enabled = FALSE on its row. A disabled repo's events are rejected (401 — GitHub will show the deliveries as failed, which is the visible signal that the integration is off) and never fall back to the global env pair, so a paused partner repo can never be re-routed to the platform tenant. Re-enable by setting enabled = TRUE.

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

  • 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.
  • 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 resolves to v4 in PT. PT declares zod ^4.0.0, so import { z } from "zod" gives PT's local Zod 4 — use the v4 surface (object-form z.enum, { error: '...' }). Do not import zod/v4 explicitly, and do not register a Zod schema imported from a shared package (root Zod 3) in the OpenAPI route registry — zod-openapi's unwrapZodObject crashes on a v3 schema (guarded by openapi-zod-boundary.test.ts, PT-409). Most other workspaces stay on Zod 3.
  • 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, local scripts (seed / setup), and the CRON_SECRET-gated cron-bypass routes (which read it per request) — but never a tenant-scoped request path.
  • 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.
get_project_overviewpt get-project-overviewFull project details including stages and custom statuses.
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).
delete_projectpt delete-projectDelete a project. Irreversible and cascades — permanently deletes ALL tasks, issues, stages, comments, and time entries.

Task tools

MCP toolpt CLI subcommandPurpose
list_taskspt list-tasksList tasks with filters (status, assignee, parent, ready / review).
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).
get_taskpt get-taskGet full task details.
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).
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).
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.
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.
add_issue_commentpt add-issue-commentAdd an issue comment; staff-only notes use MCP isInternal or CLI --internal.
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. For CLI bodies beginning with -, put options first and place -- immediately before the final body.

Other tools

MCP toolpt CLI subcommandPurpose
get_workspace_contextpt workspaceOrientation: current user, dogfood initiative, top IN_PROGRESS tasks.
consult_coordinatorpt consultAsk the hosted coordinator brain an initiative-wide question.
list_stagespt list-stagesList stage-gate pipeline stages for a project.

See also