Project Tracker module
Initiatives, projects, tasks, issues, stages, gates, time tracking, GitHub integration. Constellation dogfoods this module to track its own development — every Constellation engineering ticket lives in PT.
- Source:
apps/project-tracker/ - Schema:
projects - Project Tracker prefix:
PT-*(this module's own work; the dogfood initiative usesINF-*,PLT-*,DIR-*,CAT-*) - Hosting: sub-zone behind Directory; production basePath is
/projects/*(rewritten from the root zone). - MCP server + CLI:
tools/mcp-server/
1. Purpose
Project Tracker is where the work gets delivered. Initiatives group related projects; each project moves through a configurable pipeline of stages separated by review gates, so progress is earned through sign-off rather than drag-and-drop.
Key capabilities
- Initiatives → projects → tasks (grouped under epics) with progress roll-up — see Work hierarchy & links
- Stage-gate pipelines: reviewers vote
PASS/FAIL/WAIVE/HOLD; passing auto-progresses the stage - Issues (bugs, incidents, support tickets) with severity levels and SLA-tracked queues
- A flow engine with a dependency-gated ready set and a review queue, plus in-product AI agents that execute work when assigned or @mentioned and report back in the task thread (allowlist-gated)
- Time tracking, GitHub integration, and notifications
Who uses it
- Project and programme managers running stage-gated delivery
- Delivery teams (agency and supplier side) working tasks and issues
- Programme offices watching cross-project progress roll up to initiatives
For product context, see How the pieces fit together. The rest of this page is the technical reference.
Project Tracker is Constellation's stage-gate project coordination engine. It owns initiatives (top-level container, see glossary — supersedes the legacy "programme" term, PLT-165), projects (units of delivery), tasks (planned work) and issues (unplanned work — bugs, incidents, support tickets), with a configurable per-project stage pipeline guarded by gates and gate criteria. Two surfaces wrap the REST API for AI-agent integration: the pt CLI and the constellation MCP server. The module also subsumes the platform's "Quality / Helpdesk" responsibilities — issues with severity / SLA policies are first-class.
2. Component diagram (C4 L3)
Call direction is strict: API Route → Tool → Service → Repository. The pt CLI and MCP server hit the same REST routes — there is no second back door into the service layer. See route-wrapping.
3. Schema, tenancy & RLS
projects — owned by Project Tracker. Reads identity.* via raw SQL using IdentityUserRepo / IdentityPermissionRepo. PT's request-path code issues no direct writes to identity.*: where it needs to mutate identity (invitation acceptance, user provisioning, tenant bootstrap) it calls Directory-owned SECURITY DEFINER functions in the identity schema, which run inside PT's own transaction — see ADR-025. The CI gate npm run check:identity-writes enforces that over apps/project-tracker/src. Historical permission-seeding migrations under prisma/migrations/ and operator-run seed scripts still write identity directly; they 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_idscoping — still written explicitly, always. Services and repositories filter every query by the tenant (and, for reads, the caller's readable-project set). Never rely on RLS alone to scope a query: the explicit predicate is what makes the intent reviewable, and it is the layer that survives any future role change (the PT-564 / directory cross-tenant lesson, learned when RLS was inert). tenant_id+ RLS — an enforced backstop. Every tenant-scoped table also has RLS policies keyed on theapp.tenant_idPostgres session variable, which authentication attaches to the request (viawithTenantContext/set_config, notSET LOCAL— see §9). Since the INF-60 cutover (production has run on a non-BYPASSRLSrole since 2026-07-17) these policies are genuinely enforced, so a forgotten service-level filter is now caught by the database rather than silently leaking. One exception to keep in mind: Directory-ownedSECURITY DEFINERfunctions (see above) run as an RLS-exempt owner, so for their writes the in-function tenant assertion — not RLS — is the enforcement.- Cross-project redaction. Where a query legitimately spans projects, unreadable-project data never leaks — but via two different mechanisms. On the epic-children and initiative work-items listings, members in a project the caller cannot read are masked to an opaque
idplus the PT-283 sentinels (isCrossProject: true, key/title withheld) rather than dropped, so counts stay honest. On the label goal view, unreadable-project rows are instead omitted at the query layer, and only cross-project dependency references on the returned rows are redacted. See Work hierarchy & links § Cross-project structure and redaction.
Reads of identity.* (users, orgs, memberships) are the one cross-schema exception — via raw SQL, never a Prisma relation (§9). Identity writes are not a direct exception: they go through Directory-owned SECURITY DEFINER functions (ADR-025), so the mutation is authorised and versioned by the owning module even though it commits in PT's transaction.
4. Entities
| Aggregate | Purpose |
|---|---|
initiatives | Top-level container (e.g. Constellation Platform Development). Supersedes the legacy "programme" term (PLT-165). |
projects | Units of delivery inside an initiative. Per-project custom statuses, fields, stages, gates. |
stages | Ordered phases of a project's stage-gate pipeline. Each stage may have a gate. |
gates | Criteria-bundle that must pass for a stage to be marked COMPLETED. |
tasks | Planned work with acceptance criteria. Auto-transition to DONE on PR-merge via webhook. |
issues | Unplanned work — bugs, incidents, helpdesk. Has severity, optional sla_policy, assigneeId, transition state machine. |
time_entries | Time-tracking entries on tasks. |
feedback | Lightweight quick-feedback intake; promotes into a task or issue. |
invitations | Out-of-tenant collaborator invites. |
What counts as an "open" issue
An issue is open while its status is OPEN, IN_PROGRESS or REOPENED. RESOLVED and CLOSED are not. This single definition backs both the "N open" badge on a project's Issues card and the view the issue queue lands on when you open it, so the badge and the list beneath it always agree.
Two consequences worth knowing:
- In-progress issues are open. They are counted by the badge and shown by default in the queue. The queue's
OpenandIn progresspills are still separate filters — their counts are disjoint and sum to the badge — but both start active, so the default view is "everything still open" rather than "untouched only". Deselect either pill to narrow. - Snoozed issues are hidden everywhere. An issue snoozed until a future time is excluded from the queue and from the badge alike, and reappears in both once the snooze elapses.
Your last-used filter selection on the org-wide Issues page is remembered per user and tenant; Reset view returns you to the default above.
5. Domain events
Schemas live in @constellation/contracts and are re-exported by src/server/events/projects.events.ts. Indexed in the Domain events index.
projects.task.completed, projects.task.recurring_spawned, projects.task.unblocked, projects.gate.reviewed, projects.stage.progressed, projects.project.status_changed, projects.initiative.progress_updated (plus the legacy projects.programme.progress_updated dual-emit, PLT-182), projects.feedback.status_changed, projects.feedback.promoted, projects.feedback.promoted_to_task, projects.project.exported, projects.project.imported.
6. Public API
Project Tracker is the only Constellation app with a published auto-generated reference. See Project Tracker API. Generated from apps/project-tracker/openapi.json via docusaurus-plugin-openapi-docs. To update: edit the Zod schemas in apps/project-tracker/src/lib/openapi.ts and run npx turbo run generate-openapi --filter=@constellation/project-tracker.
Illustrative routes:
POST /api/projects— create a project under an initiative.GET /api/projects/:id/tasks— list tasks (thept list-taskssubcommand calls this).POST /api/issues/:id/transition— move an issue betweenOPEN/IN_PROGRESS/CLOSED/REOPENED.POST /api/webhooks/github— auto-transition tasks toDONEwhen their key appears in a merged PR title or body.GET /api/coordinator/cost?initiativeId=<id>&period=month— month-to-date coordinator spend for an initiative (per-user and initiative-wide token + USD totals, aggregated fromcoordinator.consults). Backs the cost tiles in Project Tracker's coordinator workspace header (/projects/coordinator/<initiative>). Tenant + initiative isolation is enforced at both the query layer and theconsults_tenant_readRLS policy.GET /api/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.
Until INF-323 the listing branch queried identity.users with no tenant scope
at all — no tenant_id predicate and no app.tenant_id context. Under the
non-bypass RLS runtime role (PLT-273) that matched zero rows, so:
- every UUID-shaped
assigneeIdwas rejected with "No user with id … exists in your active tenant", for every caller, in their own organisation — whilefind_userhappily returned those same UUIDs; - the initiative delegate search silently returned no results for anyone.
Before the RLS cutover the same unscoped query returned every tenant's users, so the guard went from false-accept to false-reject without ever being correct.
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 onuser_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, whosefindByOrgfilters onorganisation_idand 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)
This endpoint is the switch-target list — "which organisations can I switch
into?" — and it returns exactly { id, name } per organisation. It previously
returned the full organisation record; tenantId, type, status, metadata,
createdAt and updatedAt are no longer present, and have not been since the
2026-07-30 release. If you consume this endpoint outside the platform and read any
of those fields, read the full record from GET /api/organisations/{id} instead — it is
open to the same audience, since it gates on an ACTIVE membership in the organisation and
the list only ever returned organisations you are an ACTIVE member of.
The narrowing is intentional. All four Constellation zones (Directory, catalog, wiki, project-tracker) now answer this question through one shared implementation, so they cannot disagree about which organisations a user can reach — see ADR-023. Restoring project-tracker-only fields would re-create the drift that ADR exists to end.
Two behaviours of the list are worth knowing, because the payload does not show them:
- Rows span all tenants the caller holds an ACTIVE membership in, and the acting tenant's organisation is returned first. That ordering is load-bearing for clients that select a default.
- Organisations in tenants that are not
ACTIVEare omitted — including the acting tenant's own. PLT-476 exempted the acting tenant because the resolver still permitted acting inside a disabled one; PLT-477 moved the gate into both membership resolvers, so a non-ACTIVE tenant can no longer be entered at all and the exemption was removed. - The list is empty when the acting tenant contributes no visible organisation — even
if other tenants did produce rows. That is fail-closed on purpose: the switcher writes
organisations[0]intox-active-org, so a foreign tenant must never occupy the first position. Removing the liveness exemption above did not relax this.
The organisation switcher's "Create Organisation" entry (PLT-627)
Project Tracker is the only zone whose organisation switcher offers a create
entry, because its POST /api/organisations provisions a tenant, an organisation
and your ACTIVE membership together — so what it creates is immediately a switch
target. Catalog, Wiki and Directory show no create entry.
That is narrower than "creating an organisation is PT-only": Directory still creates
organisations through its own /organisations/new form. Those are tenant-local records
rather than new workspaces you join, which is exactly why that form is not wired to the
switcher. The full explanation lives with the zone that owns identity.* — see
Directory § Creating an organisation.
Organisation settings & roles access (PT-877)
The organisation settings page (/settings/organisation) gates its sections
independently rather than behind a single permission:
| Section | Required permission |
|---|---|
| Organisation profile, subscription & usage | org.settings.manage |
| Members | org.settings.manage to see it, org.members.manage for its data |
| Roles | org.roles.manage |
| Invitations | org.invitations.manage |
Page admission is the union of the three SECTION permissions —
org.settings.manage, org.roles.manage, org.invitations.manage. A principal
holding any one of those reaches the page and sees the sections it covers; a
principal holding none is refused. org.members.manage is not an admission
permission: it is only the data requirement for the members section, which
renders under org.settings.manage, so a caller holding just
org.settings.manage sees that section with an empty list because
GET /api/organisations/{id}/members rejects them. That split predates this
change and is unaltered by it. The Invite
people entry in the Create menu and the command palette is gated on
org.invitations.manage, matching the card it navigates to. The page does not
issue the organisation or members reads for a viewer without
org.settings.manage, and skips the roles read for anyone without
org.roles.manage — a roles-only caller does issue it. (The invitations
card issues one of its own to populate the invitation form — see the first
caveat below.)
Two caveats, both pre-existing behaviour rather than part of this gating:
- The invitations card fetches before its own gate.
InvitationManagerevaluates itsPermissionGateonly after issuing its reads and rendering a loading card, so the page mounts it solely fororg.invitations.manageholders rather than relying on that internal gate alone. - Access assumes the active-org cookie resolves to the selected tenant. That
holds for every seeded tenant, where an organisation's id and tenant id
coincide. Where an organisation's id and tenant id diverge,
/api/auth/permissionscan resolve a fallback tenant, so holding a permission in the selected organisation does not by itself guarantee the page admits the principal. See the residuals inSPEC-pt-877.
GET /api/organisations/{id}/roles requires an active membership in the
organisation, and resolves both the permission check and the role read against
that membership's tenant — not the path organisation id, which the Directory
schema does not guarantee to be equal. Callers with org.roles.manage receive
full role objects; callers with only org.invitations.manage receive the
minimal {id, name} projection used by the invitation form's role selector.
Denials use the standard { error: { code, message } } envelope: FORBIDDEN
when there is no active membership, MISSING_PERMISSION when neither permission
is held.
Invitation creation gating (PT-888)
POST /api/organisations/{id}/invitations carries two flows behind one URL and
gates them separately. Which flow runs is decided by the request body: a
projectId selects the project-scoped branch, its absence the org-level one.
| Branch | Required permission | Additional check |
|---|---|---|
Project-scoped (projectId present) | projects.collaborators.manage or org.invitations.manage | Caller must be able to reach the project |
Org-level (no projectId) | org.invitations.manage | — |
A project-scoped invitation grants access to one project as a guest
collaborator — the same act POST /api/projects/{id}/collaborators performs for
someone who already has an account, which is gated on
projects.collaborators.manage. Of the seeded default roles only Admin holds
org.invitations.manage, so gating both branches on it left Project Manager —
the role that actually runs a project — able to re-role and remove collaborators
but unable to invite anyone who has no account yet. The org-level branch keeps
requiring org.invitations.manage alone: it adds a member to the
organisation with an org role, which is a genuinely org-admin act.
Enforcement order is membership → union pre-gate → body parse → the branch's own requirement where it is narrower. The pre-gate accepts either permission and runs before Zod, so a caller holding neither cannot use a malformed body to probe the request schema. Only the org-level branch is re-checked — the union pre-gate already is the project branch's requirement.
A caller who fails the pre-gate holds neither permission and receives
403 { error: "Forbidden: requires one of 'projects.collaborators.manage' or 'org.invitations.manage'" },
phrased as alternatives because the endpoint cannot yet know which branch was
intended. A caller who clears the pre-gate but lacks org.invitations.manage on
the org-level branch gets the narrower
403 { error: "Forbidden: missing permission 'org.invitations.manage'" }.
The project branch also verifies project access, which the endpoint
previously did not — an org.invitations.manage holder could otherwise place a
guest collaborator into any project in the tenant, including projects they cannot
open. Every refusal on that axis is the same uniform
403 { error: "Forbidden", code: "FORBIDDEN" }: an inaccessible project, a
non-existent one, and a project belonging to another tenant are
indistinguishable, so the endpoint is not a project-existence oracle.
It admits any active INTERNAL member holding full projects.read, and
Project Manager is granted the whole projects resource — so for that role the
check passes for every project in the organisation. The effective capability
is therefore "any Project Manager can invite an arbitrary external email address
onto any project in the organisation", creating an identity and a
Guest Customer organisation membership on acceptance. That is the same reach
projects.collaborators.manage already carries on
POST /api/projects/{id}/collaborators. The two do not gate identically: that
sibling authorizes through its own helper and does not call verifyProjectAccess,
so this branch is stricter by exactly one condition — a custom role holding
projects.collaborators.manage without 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_RESOLVEDgate criterion (PT-48), knowledge-base space references (PT-374), and the multi-orgx-act-as-orgheader (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):
- 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)
- Encrypt + upsert the config row with the operator script (never hand-craft the ciphertext — a plaintext
INSERTproduces a valuedecryptSecretrejects). The script reads the sameGITHUB_INTEGRATION_ENCRYPTION_KEYandDATABASE_URL/DIRECT_URLthe deployed app uses, and upserts keyed on the lowercasedrepo_full_name(re-running it rotates the secret in place). It validates that--projectbelongs to--org, and refuses to move an already-mapped repo to a different tenant unless--reassign-tenantis passed explicitly:# Pipe the secret — running without a pipe prompts on stdin, but the input is NOT maskedprintf '%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 - 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/projectsprefix 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), soissuesevents 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.
- Payload URL:
- 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 withpr_linkwritten.
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
| Layer | Path | May import from |
|---|---|---|
| API Routes | src/app/api/ | Tools only |
| Tools | src/server/tools/ | Services, Policies, Events |
| Services | src/server/services/ | Repositories, Policies, @constellation-platform/db, @constellation-platform/audit |
| Repositories | src/server/repositories/ | @constellation-platform/db (Prisma for projects.*, raw SQL for identity.*) |
| Policies | access-control helpers | @constellation-platform/auth-core |
| Workflows | src/server/workflows/ | Tools or Services (never repositories) |
| Events | src/server/events/ | @constellation-platform/events publish() + outbox |
Enforced for catalog and directory by scripts/check-route-wrapping.ts (invoked via npm run check:routes); Project Tracker is not in that script's ROUTE_ROOTS today, so PT relies on code-review + the per-app authedRoute() helper to keep every route wrapped. Plus ESLint import boundaries across all three apps.
8. Code entry points
- Tool:
apps/project-tracker/src/server/tools/task.tools.ts— task CRUD, transitions, bulk ops. - Service:
apps/project-tracker/src/server/services/task.service.ts— task lifecycle, progress cascade triggers. - Identity reader (raw SQL):
apps/project-tracker/src/server/repositories/identity.repository.ts. - Events module:
apps/project-tracker/src/server/events/projects.events.ts. - Workflow:
apps/project-tracker/src/server/workflows/progress-cascade.workflow.ts— task completion cascades to stage / project / initiative progress. - GitHub webhook:
apps/project-tracker/src/app/api/webhooks/github/route.ts— task auto-close regex/\[([A-Z][A-Z0-9]{1,9}-\d+)\](?!\()/gi. - OpenAPI source:
apps/project-tracker/src/lib/openapi.ts.
9. Known exceptions / pitfalls
- Production basePath is
/projects. Any external integration URL must include the prefix — e.g. the GitHub webhook lives athttps://constellation.planetb2b.com/projects/api/webhooks/github. Local dev runs at/becauseNEXT_PUBLIC_BASE_PATHis unset. See Deployment. fetch()does not get basePath auto-prepended. UseapiUrl()fromapps/project-tracker/src/lib/api-url.tsfor any client-sidefetch().<Link>androuter.push()are auto-prefixed by Next.js.- PT bracket-key footgun — narrower than it used to be. The GitHub webhook matches a bare
[KEY-123]anywhere in a PR title or body and auto-closes that task on merge, so a release body listing several bare bracketed keys closes all of them. It no longer matches the markdown-link form[KEY-123](url):BRACKETED_KEY_REFERENCE_REis/\[([A-Z][A-Z0-9]{1,9}-\d+)\](?!\()/gi, whose(?!\()negative lookahead was added by PT-345 precisely so that reviewers' pasted hyperlinks are references rather than close markers. (Note the key shape too: two-to-ten characters, letters and digits only — an underscore never matched, contrary to what several older copies of this regex in the docs claimed.) Prefer plainINF-30when you only want to reference — it is unambiguous and does not depend on that lookahead — but do not assume a[KEY](url)link will close a ticket, because it will not. - Identity reads are raw SQL; identity writes go through Directory. Don't try to add Prisma relations from
projects.*toidentity.*— they are in different schemas. UseIdentityUserRepo/IdentityPermissionRepoto read. To mutate identity, call one of the Directory-ownedSECURITY DEFINERfunctions (identity.grant_invitation_access,identity.provision_user,identity.merge_user_metadata,identity.update_organisation_profile,identity.bootstrap_tenant) rather than writing raw SQL —npm run check:identity-writesfails the build otherwise. Adding a new one means a new Directory migration; see ADR-025. - Zod resolves to v4 in PT. PT declares
zod ^4.0.0, soimport { z } from "zod"gives PT's local Zod 4 — use the v4 surface (object-formz.enum,{ error: '...' }). Do not importzod/v4explicitly, and do not register a Zod schema imported from a shared package (root Zod 3) in the OpenAPI route registry —zod-openapi'sunwrapZodObjectcrashes on a v3 schema (guarded byopenapi-zod-boundary.test.ts, PT-409). Most other workspaces stay on Zod 3. - Don't hand-roll
SET LOCALin raw SQL. A bareSET LOCALoutside a transaction is a no-op, and through the transaction-mode pooler a standalone statement can land on a different server connection than your next query. The helpers (withTenantContext,runRawWithTenant) runSELECT set_config('app.tenant_id', $1, true)inside the interactive transaction on the pooledDATABASE_URL— transaction pooling pins the whole transaction to one server connection, so this is safe (ADR-019).DIRECT_URL(Supavisor session mode, port 5432) is for Prisma CLI migrations, local scripts (seed / setup), and theCRON_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 againsttask_links:task_links.project_idis a denormalization of the SOURCE task's project, not a partition key. Do not filter a link read by it — a database trigger repoints it when the source task moves, so filtering by it silently drops every edge whose source has moved. Scope link reads by their endpoints (source_task_id/target_task_id) instead.- A
BLOCKSpredecessor in another project still blocks. Readiness is evaluated one project at a time, so a foreign predecessor is absent from the local status map and must have its doneness resolved explicitly; otherwise a task is either wrongly ready or wrongly pinned out forever.loadBlocksEdgesForTasksdoes this for PT, and the coordinator does it for whatever rows its host injects. - Both endpoints are authorized. Creating or deleting a cross-project link requires access to the OTHER endpoint's project too — a
BLOCKSedge changes what is ready there. An unauthorized or absent target returns the same error, so the check is not an existence oracle.
getCurrentUser()is expensive in tenant-wrapped handlers. PrefergetCachedUser() ?? await getCurrentUser().- The
ptCLI and MCP server hit the same REST API. They are NOT a second back door — auth, RLS, and audit apply equally. See CLI overview.
10. MCP / CLI tool reference
The constellation MCP server and pt CLI expose the following tools and subcommands for agents. Both surfaces call the same PT REST API.
Initiative tools (renamed from "programme" in PLT-165)
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
list_initiatives | pt list-initiatives | List all initiatives with project counts and progress. |
get_initiative_summary | pt get-initiative-summary | Get one initiative with full project list. Use at session start for orientation. Accepts UUID or name for initiativeId. |
create_initiative | pt create-initiative | Create a new initiative (always ACTIVE, no project links). Required: name, startDate. Optional: description, endDate. |
update_initiative | pt update-initiative | Update an initiative's name, description, status, dates, or knowledgeBaseSpaceIds (wiki KB space UUIDs, max 10). Accepts UUID or name. |
delete_initiative | pt delete-initiative | Delete an initiative. Cascades — removes the record and unlinks projects (projects are NOT deleted). |
Breaking change (PLT-165): the old MCP tools
list_programmesandget_programme_summaryand thept programmecommand are removed. Use the_initiativeequivalents. TheprogrammeIdparameter onget_programme_summaryis renamed toinitiativeId.
Project tools
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
list_projects | pt list-projects | List all projects (status, progress, task count). |
find_project | pt find-project | Resolve a hint (UUID, name, prefix, or repo path) to a project UUID. |
get_project_overview | pt get-project-overview | Full project details including stages and custom statuses. |
create_project | pt create-project | Create a project. Required: name, startDate. Optional: description, endDate, prefix, initiativeId (UUID or name). |
update_project | pt update-project | Update a project's settings. Optional: name, description, status, startDate, endDate, prefix, issuePrefix, initiativeId, knowledgeBaseSpaceIds (wiki KB space UUIDs, max 10). |
delete_project | pt delete-project | Delete a project. Irreversible and cascades — permanently deletes ALL tasks, issues, stages, comments, and time entries. |
Task tools
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
list_tasks | pt list-tasks | List tasks with filters (status, assignee, parent, ready / review). |
list_epic_children | pt list-epic-children | List an epic's children across all projects in the tenant (PT-491). Children in projects you can't read are redacted (opaque id + isCrossProject: true). |
get_task | pt get-task | Get full task details. |
create_task | pt create-task | Create a planned-work task. Optional epicId (group under an epic). Tag an EPIC to an initiative with MCP initiativeId (UUID only) or the CLI --initiative <id-or-name> (resolves a name or UUID) (INF-198). |
update_task | pt update-task | Update a task (status, priority, assignee, etc.). Tag/detach an EPIC's initiative with MCP initiativeId (UUID only) or the CLI --initiative <id-or-name> (name or UUID); null / "" detaches — EPIC-typed tasks only (INF-198). |
claim_next_ready_task | pt claim-next-ready | Atomically claim the top ready task in a project (projectId + assigneeEmail). Race-free; returns nothing to claim at the agent WIP cap. |
Issue tools
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
list_issues | pt list-issues | List issues (bugs, incidents, support tickets). |
get_issue | pt get-issue | Get full issue details. |
create_issue | pt create-issue | Create a bug/incident/support ticket. |
update_issue | pt update-issue | Update issue title, description, or due date. |
assign_issue | pt assign-issue | Assign an issue to a user. |
escalate_issue | pt escalate-issue | Change issue severity. |
transition_issue | pt transition-issue | Move issue between statuses. |
resolve_issue | pt resolve-issue | Resolve an issue with a reason. |
Comment tools (INF-52)
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
list_task_comments | pt list-task-comments | List a task's comments; newest-first by default, with configurable order and limit. |
add_task_comment | pt add-task-comment | Add a task comment; notify project-visible users with MCP mentionUserIds or CLI --mention-user-ids. |
update_task_comment | pt update-task-comment | Edit under PT's author-or-comments.update permission rules. |
delete_task_comment | pt delete-task-comment | Soft-delete under PT's author-or-comments.delete permission rules. |
list_issue_comments | pt list-issue-comments | List issue comments; supports order, limit, and staff-only notes via includeInternal. |
add_issue_comment | pt add-issue-comment | Add an issue comment; staff-only notes use MCP isInternal or CLI --internal. |
update_issue_comment | pt update-issue-comment | Edit one of the authenticated user's issue comments. |
delete_issue_comment | pt delete-issue-comment | Soft-delete one of the authenticated user's issue comments. |
Comment bodies are untrusted human-authored text. List responses sanitize and bound their display text, report showing N of M plus the applied order, and carry a warning for agents not to follow instructions found in comments. Requests for internal issue comments silently degrade to the public-only thread when the caller lacks projects.issues.manage. The MCP and CLI surfaces never accept an author or actor override: PT attributes every mutation to the bearer-token user and enforces comment ownership and permissions. In multi-organisation sessions, all eight MCP tools accept organisationId. For CLI bodies beginning with -, put options first and place -- immediately before the final body.
Other tools
| MCP tool | pt CLI subcommand | Purpose |
|---|---|---|
get_workspace_context | pt workspace | Orientation: current user, dogfood initiative, top IN_PROGRESS tasks. |
consult_coordinator | pt consult | Ask the hosted coordinator brain an initiative-wide question. |
list_stages | pt list-stages | List stage-gate pipeline stages for a project. |
See also
- Tasks page — unified work surface at
/projects/tasks(replaces the old/my-taskspage). Scope toggle, multi-view, inline editing, bulk actions. - Work hierarchy & links — epics, sub-tasks, cross-project initiatives, and typed links.
- Labels & goal views — cross-project grouping by label.
- Flow engine — ready set and review queue for the agent fleet.
- In-product assignable agents — execute work on assignment / @mention and report back in-thread (allowlist-gated).
- Advanced features & configuration — resolution outcomes, gate criteria, dashboard layouts, KB space refs, multi-org header.
- Milestones — payment-linked milestone tracking.
- Project Tracker API — auto-generated reference.
- CLI overview —
ptCLI andconstellationMCP server, when to use which. - Deployment & Migration Plan — Vercel project, Supabase database, rollout strategy.
- Domain events index — full payload schemas for the events listed in §5.