Skip to content

Projects Internals 3 Services and API

Ed Mozley edited this page Oct 9, 2026 · 3 revisions

πŸ› οΈ Projects internals 3 - Services and API

Every write in the Projects module goes through two service classes, ProjectsService (includes/services/projects.php) and ProjectToolsService (includes/services/project_tools.php), helped by ProjectReportsService and ProjectTemplatesService. Four doors reach them: the screens' own JSON endpoints in api/projects/, the REST API v1 (api/v1/resources/projects.php), and - read-only - the MCP server and Warbot, which share one file of answers (includes/projects/assistant.php). This page documents the services method by method with the rule each one enforces, then every endpoint with its parameters, actions, permissions and response shape, then the REST routes, then the two assistant doors. Schema detail is on page 2; the plan-specific writes (stages, tasks, milestones, dependencies) are explained in depth on page 4.

Pages in this series


Contents

  1. The shape of a write
  2. ProjectsService
  3. ProjectToolsService
  4. ProjectReportsService and ProjectTemplatesService
  5. The endpoint bootstrap
  6. Every endpoint in api/projects/
  7. api/projects/tools.php - every action
  8. Calling the endpoints from the page: P.api()
  9. REST API v1
  10. MCP and Warbot: one file of answers
  11. Adding an endpoint or an action

1. The shape of a write

projects/*.php + assets/js/projects*.js
        β”‚  fetch, JSON (X-CSRF-Token added by assets/js/csrf.js)
        β–Ό
api/projects/*.php ── includes/projects/api_bootstrap.php
        β”‚               (session, requireModuleAccessJson('projects'), $conn, $ctx, $analystId)
        β”‚
        β”œβ”€β”€ ProjectsService ─────────► projects, project_stages, project_audit, tasks.project_id
        β”‚        └─ createTaskInProject() ─► TasksService::saveTask()
        β”œβ”€β”€ ProjectToolsService ─────► members, items, RACI, RAID, tolerances, gates, budget,
        β”‚        β”‚                     milestones, dependencies, benefits, change control ...
        β”‚        └─ every method: ProjectsService::loadForActor() + assertCanChange()
        β”œβ”€β”€ ProjectReportsService ───► project_reports (+ includes/projects/ai.php)
        └── ProjectTemplatesService ─► project_templates (+ includes/projects/templates.php)

api/v1/resources/projects.php ──► the same services, ActorContext::fromApiKey()
api/mcp/index.php + includes/mcp/tools.php ─┐
includes/warbot/tools.php ──────────────────┴► includes/projects/assistant.php (read-only)

The rules that hold everywhere:

  • Writes are unified, reads stay per surface - the Service Layer rule. The services never emit HTTP. They return ids (or small arrays) or throw ServiceError($kind, $code, $message). Each door turns that into its own error shape: projectApiRun() into {success:false, error}, apiFailFromService() into the REST error body with an HTTP status, Warbot into a sentence for the room, MCP into a tool result with isError: true.
  • Every by-id method starts with ProjectsService::loadForActor(). A project outside the caller's companies, or a members-only project they may not see, is not found, never forbidden, so ids cannot be probed.
  • Every change checks ProjectsService::assertCanChange() (Projects β†’ Settings β†’ General β†’ Who may change a project). ProjectToolsService has no permission rules of its own: its private changeable() is exactly loadForActor() then assertCanChange().
  • A system actor is not asked. ActorContext::actorId 0 (the demo importer, scheduled work, the form action that creates a proposal) passes assertCanChange() and the create policy, because it is not a person.
  • source on the history row is api when the ActorContext came from an API key, otherwise app (ProjectsService::source(), ProjectToolsService::src()).
  • Writes are POST only in api/projects/, so a link or an image tag can never change a project. CSRF is enforced centrally by includes/request_guard.php; nothing in the module opts in. See CSRF protection.

ServiceError kinds and how they surface:

kind Thrown for REST status (serviceErrorHttpStatus()) api/projects/
validation a bad or missing field, a rule refused (missing_field, invalid_field, checklist_open, not_ready, ambiguous) 422 HTTP 200, {success:false, error}
bad_request an unparseable date ("2026-13-01" is not a date.) 400 HTTP 200, {success:false, error}
not_found no such row, or out of scope 404 HTTP 200, {success:false, error}
forbidden a permission rule 403 HTTP 200, {success:false, error}
conflict a duplicate member, a lesson already an article, an issue that already has a ticket 409 HTTP 200, {success:false, error}
unavailable not_ready before Database Verification (a few methods) 422 (the default branch) HTTP 200, {success:false, error}

Note

The screens' endpoints answer a refusal with HTTP 200 and success: false - projectApiFail() only sets a status when one is passed (401 for no session, 403 from the settings and templates endpoints' capability checks, 405 for a GET to a POST-only endpoint). The page's P.api() reads success, not the status. The REST API is the one that maps kinds to statuses.


2. ProjectsService

includes/services/projects.php - projects, their stages, putting tasks in and out, and the events and history every other write reuses.

2.1 fieldMap() - the writable project fields

The one list of what a person or an API key may set on a project, and how each is validated. createProject(), updateProject() and the REST apiProjectFields() all start from it, so a body can never reach a column that is not on it (tenant_id, created_by_id, approval_*, currency, the alert columns are all absent on purpose).

public static function fieldMap(): array
{
    return [
        'name'             => ['type' => 'string', 'max' => 200, 'required' => true],
        'summary'          => ['type' => 'text',   'max' => 20000],
        'goal'             => ['type' => 'string', 'max' => 500],
        'methodology'      => ['type' => 'enum',   'values' => array_keys(projectMethodologies())],
        'status'           => ['type' => 'enum',   'values' => projectStatuses()],
        'health'           => ['type' => 'enum',   'values' => projectHealthValues()],
        'health_note'      => ['type' => 'string', 'max' => 500],
        'priority'         => ['type' => 'enum',   'values' => projectPriorities()],
        'owner_analyst_id' => ['type' => 'analyst'],
        'start_date'       => ['type' => 'date'],
        'target_end_date'  => ['type' => 'date'],
        'actual_end_date'  => ['type' => 'date'],
        'colour'           => ['type' => 'enum',   'values' => array_keys(projectColours())],
        'icon'             => ['type' => 'enum',   'values' => projectIcons()],
        'business_case'    => ['type' => 'text',   'max' => 50000],
        'tailoring'        => ['type' => 'tailoring'],
        // Intake (3.3.0): a proposal's own figures - includes/projects/intake.php.
        'estimated_cost'   => ['type' => 'money'],
        'estimated_benefit'=> ['type' => 'text',   'max' => 5000],
        // Members-only (3.3.0): includes/projects/visibility.php.
        'visibility'       => ['type' => 'enum',   'values' => ['everyone', 'members']],
    ];
}

validateField() per type:

Type Rule
string trimmed; blank = NULL unless required (then Give the project a name.); over max is refused, never cut
text as string, no required
enum must be one of values exactly (case-sensitive)
date Y-m-d and a real date (DateTime::createFromFormat('!Y-m-d') must round-trip), else bad_request
tailoring an array or a JSON string; only keys in projectToolDefinitions() survive, each cast to bool; nothing left = NULL ("the method's defaults")
money (3.3.0) commas and spaces stripped, ^\d{1,15}(\.\d{1,2})?$, stored as sprintf('%.2f') - the same two-decimal string MySQL hands back for a DECIMAL(18,2), so same() sees an unchanged value as unchanged
analyst blank or <= 0 = NULL; otherwise an active analyst

assertDates($start, $end) refuses an end before a start, for the project's start/target pair and for every stage.

2.2 saveProject(), createProject(), updateProject()

public static function saveProject(PDO $conn, ActorContext $ctx, array $in, ?int $tenantId = null): array
// ['id' => int, 'created' => bool] - updateProject() when $in['id'] is set, else createProject()

createProject(PDO $conn, ActorContext $ctx, array $in, ?int $tenantId): int, in order:

  1. storeTenant() turns the Default company into NULL (the way every scoped table stores it). With a companyScope, the effective company must be in it (You cannot add projects for that company.).
  2. projectCanCreate() for a person (create policy - anyone or managers).
  3. A name is required.
  4. Defaults filled when the key is absent (an explicit value always wins): methodology from project_default_method; visibility from project_default_visibility (3.3.0, only once the column exists); owner_analyst_id = the creator.
  5. Intake (3.3.0): projectProposalNeedsApproval($conn, !empty($in['_from_form'])) reads project_proposal_approval (forms / all / off). When it says yes, status is forced to proposed and approval_status = 'pending' is written.
  6. Every fieldMap() key present is validated and inserted. estimated_* are skipped before projectIntakeReady(), visibility before projectVisibilityReady(), so a create never fails on an install that has not run Database Verification.
  7. A finished status (closed / cancelled) stamps closed_datetime.
  8. Afterwards, outside any transaction: audit('project_created'), projectStampCurrency() (the budget currency is fixed now, see page 6), syncCalendar(), dispatch('project.created'), proposalEvent('project.proposal_submitted') when pending and not from a form (the form action fires it itself once the proposer is stamped), then afterChange().

updateProject(PDO $conn, ActorContext $ctx, int $id, array $in): int:

  1. loadForActor() + assertCanChange().
  2. For each fieldMap() key present and present as a column on the row (array_key_exists($field, $cur) - a column Database Verification has not added yet is skipped rather than failing the save), validate, and keep it only if same() says it changed. Nothing changed = return early, no history, no events.
  3. Visibility (3.3.0): changing visibility needs projectIsTeam() || projectIsManager() on top of the change policy - under "anyone may change", an outsider must not be able to shut the door.
  4. Intake (3.3.0): projectProposalBlocksStatus() - a pending proposal may only stay proposed or go to cancelled (This proposal is waiting for approval. It can start once it is approved.).
  5. Dates checked as a pair, using the new value where one was sent.
  6. Status transitions: becoming finished stamps closed_datetime and, for closed with no actual_end_date set or sent, actual_end_date = UTC_DATE(); leaving a finished status clears closed_datetime.
  7. One transaction: the UPDATE, and applyMethodology() when methodology changed (page 4 Β§2).
  8. After commit: one audit() row per changed field, with auditDisplay() values; projectBaselineAuto(..., 'start') when the status became active (change control, page 6); syncCalendar(); dispatch('project.updated', ['changed' => 'status,target_end_date']); afterChange().

auditDisplay() writes names, not ids: owner_analyst_id becomes the analyst's name (#12 if gone), tailoring is stored as NULL, and summary / business_case are cut to 117 characters plus .... The History tab (assets/js/projects-view.js) then shows changed the business case with no text at all for colour, icon, tailoring and business_case.

2.3 deleteProject(PDO $conn, ActorContext $ctx, int $id): array

Returns ['id' => $id, 'tasks_detached' => n]. projectCanDelete() - the owner, the creator, or a manager, never affected by a setting. The event payload is read before the delete (eventFor()), because afterwards there is nothing to read. Then one transaction:

$st = $conn->prepare("UPDATE tasks SET project_id = NULL, project_stage_id = NULL WHERE project_id = ?");
// Benefit measurements, then RAID action joins (both via their parents), then:
foreach (['project_raci', 'project_members', 'project_items', 'project_raid', 'project_tolerances', 'project_budget_lines',
          'project_reports', 'project_milestones', 'project_change_requests', 'project_baselines', 'project_benefits',
          'project_gate_items', 'project_task_flow'] as $t) {
    try { $conn->prepare("DELETE FROM `$t` WHERE project_id = ?")->execute([$id]); } catch (Throwable $e) { /* not created yet */ }
}
foreach (['project_stages', 'project_audit'] as $t) { ... }   // no try: these always exist
// The project's own hourly rates (3.3.0, #2242): project_labour_rates has no project FK (ref_id is polymorphic)
try { DELETE FROM project_labour_rates WHERE scope = 'project' AND ref_id = ? } catch (Throwable $e) {}
projectLinksDeleteAll($conn, $id);                               // the six link tables, each in its own try
UPDATE status_planned SET project_id = NULL WHERE project_id = ? // announced disruption stays, unlinked
DELETE FROM projects WHERE id = ?

πŸ”‘ Tasks are detached, never deleted - the work people did must survive a project being tidied away. Every child table is deleted by hand even though the foreign keys cascade, because an upgraded install whose FKs failed to add has no cascade to rely on; each in its own try, so a table not created yet never stops a delete. After commit: syncCalendar() and project.deleted with the payload read earlier.

2.4 Loading and scope

Method What it does
loadRow($conn, $id) SELECT * FROM projects WHERE id = ?, or not_found. No scope - only for internal use after a scope check
loadForActor($conn, $ctx, $id) loadRow(), then assertScope(), then projectVisibleTo() (members-only, 3.3.0). Either failure is not_found (Project not found.)
loadStage($conn, $id) one project_stages row or not_found; callers check project_id themselves
assertScope() (private) no-op with no companyScope or on a single-company install; otherwise the row's company (NULL read as Default) must be in $ctx->companyScope
assertCanChange($conn, $ctx, $project) public; actorId <= 0 passes; otherwise projectCanChange() or forbidden (Only this project's team, or someone who manages Projects, can change it.)
sameTenant() (private) true on a single-company install; otherwise the two companies equal, NULL read as Default
storeTenant() (private) NULL or <= 0 β†’ NULL; the Default company β†’ NULL; anything else unchanged

2.5 Stages

saveStage(PDO $conn, ActorContext $ctx, int $projectId, array $in): int, deleteStage(..., int $projectId, int $stageId): void, reorderStages(..., int $projectId, array $ids): void. Their rules - the preset's timebox as the new stage's kind, single_active, the automatic baseline on start, project.stage_closed, milestones and gate items on delete - are on page 4 Β§2. In short: the stage must belong to the project (not_found otherwise), a name of at most 150 characters is required, goal is cut to 500, and every field left out keeps its stored value, so a body of {id, name, status} changes only the status.

2.6 Tasks in a project

  • createTaskInProject(PDO $conn, ActorContext $ctx, int $projectId, ?int $stageId, array $in): int - makes the task with TasksService::saveTask(), the one create path, then files it under the project. It strips id, ticket_id and parent_task_id and passes the project's company explicitly. Full walk-through on page 4 Β§3.
  • assignTask(PDO $conn, ActorContext $ctx, int $taskId, ?int $projectId, ?int $stageId = null): void - in, between stages, or out ($projectId null). A subtask is refused when put into a project (A subtask goes with its parent task - put the parent in the project., #2242); taking one out is allowed. The task must be in scope; the caller must be allowed to change the project it is leaving and the one it joins; the task and the project must be in the same company (sameTenant()); a stage must belong to the project.

2.7 History, touch, calendar and alerts

public static function audit(PDO $conn, int $projectId, ?int $analystId, string $field, $old, $new, string $source = 'app'): void

Writes one project_audit row; analyst_id NULL for the system; old and new cut to 1000 characters. It never throws - a history row that cannot be written is logged, never a failed save. field_name is a free key (stage_status, raid_escalated, change_approved ...); the History tab turns each into words through history.* strings.

Helper What it does Throws?
touchProject($conn, $id) (public wrapper of touch()) updated_datetime = UTC_TIMESTAMP() - used by every tool write so the portfolio's "updated" is honest yes (a plain UPDATE)
syncCalendar($conn) projectSyncCalendar() - the full redraw of the Calendar's project entries (page 4 Β§9). Call it after the write, outside any transaction never
afterChange($conn, $projectId) projectAlertsScan($conn, $projectId) - fire what the project's health and tolerances now say, as the person who made the change (page 7) never
dispatch() (private) projectDispatch($event, ['project' => eventFor()] + $extra) no
eventFor() (private) projectAlertRows($conn, $id)[0] through projectEventPayload() - the project as its events carry it, with the health it shows; null if it is gone never

2.8 Events fired from the service

All go through projectDispatch() (includes/projects/alerts.php) β†’ WorkflowEngine::dispatch(), so one call reaches workflows, webhooks and the bell. Each helper wraps itself in try and logs: an event that cannot be built never fails the write that caused it.

Method Event Payload beyond project Fired from
dispatch() project.created / project.updated (changed: comma list of fields) / project.deleted - the three project writes
stageClosed($conn, $pid, $stageId, ?$decision, ?$next) project.stage_closed stage {id, name, kind, end_date, status}, gate_decision, next_stage, unapproved_changes (count from projectUnapprovedChanges()) saveStage() when status becomes closed (decision null); decideGate() when it closes the stage
raidEscalated($conn, $pid, $r) (3.3.0) project.raid_escalated raid {id, type, title, due_date, owner_analyst_id}, note ProjectToolsService::escalateRaid()
signoffEvent($conn, $pid, $itemId) (3.3.0) project.signoff_requested item {id, title, analyst_id}, stage {id, name}, notify_ids: [analyst] saveGateItem() when a sign-off item gets (or changes) its person
proposalEvent($conn, $pid, $event) (3.3.0) project.proposal_submitted / project.proposal_decided proposal {status, estimated_cost, estimated_benefit, business_case, notes, decided_by_id, proposed_by_name, proposed_by_email, proposed_by_analyst_id, submission_id}, notify_ids (approvers from projectProposalApprovers(), or the creator) createProject(), decideProposal(), the form action
changeEvent($conn, $pid, $event, $cr) (3.3.0) project.change_raised / project.change_decided change_request {id, number, reference: "CR-3", title, status, impact_days, impact_cost, impact_scope, raised_by_id, decided_by_id, decision_notes} saveChangeRequest() (create), decideChangeRequest()
milestoneReached($conn, $pid, $m) (3.3.0) project.milestone_reached milestone (projectMilestoneEventFields()), late_days (0 when on time) saveMilestone() when done_date goes from empty to set

The time-based events (health_changed, tolerance_breached, stage_due, milestone_due, milestone_missed, the nudges, benefit reviews, scheduled reports) come from the alert scan, not from here - see page 7.

2.9 decideProposal() (3.3.0)

public static function decideProposal(PDO $conn, ActorContext $ctx, int $projectId, string $decision, $notes): array
// ['status' => 'proposed' | 'active' | 'cancelled']

loadForActor(); decision is approved or rejected; the project must be approval_status = 'pending'; projectCanDecideProposal() (the named approver while active, or Manage Projects); a reason is required to reject. Rejecting sets status = 'cancelled' and closed_datetime; approving leaves it proposed, or makes it active when project_proposal_on_approve = active (then projectBaselineAuto(..., 'start')). The UPDATE is compare-and-set (... WHERE id = ? AND approval_status = 'pending') and checks rowCount() === 1, so two approvers pressing at once decide it once. Audited proposal_approved / proposal_rejected (+ a status row when it changed). The intake rules are on page 6.


3. ProjectToolsService

includes/services/project_tools.php. Kept apart from ProjectsService so neither grows into one enormous file, with no permission rules of its own. The pattern of every public write:

public static function deleteTarget(PDO $conn, ActorContext $ctx, int $projectId, int $targetId): void
{
    self::changeable($conn, $ctx, $projectId);          // loadForActor() + assertCanChange()
    $t = self::target($conn, $projectId, $targetId);    // the child must belong to THIS project
    $conn->prepare("DELETE FROM project_asset_targets WHERE id = ?")->execute([$targetId]);
    ProjectsService::audit($conn, $projectId, $ctx->actorId, 'target_removed', $t['name'], null, self::src($ctx));
    ProjectsService::touchProject($conn, $projectId);
    ProjectsService::afterChange($conn, $projectId);    // only where health or a tolerance can move
}

Every child is looked up as WHERE id = ? AND project_id = ? (member(), item(), raidRow(), target(), milestone(), gateItem(), benefit(), changeRequest()), so an id from another project is not part of this project, never a cross-project write. The private helpers gateItem(), benefit() and changeRequest() turn a missing table into not_ready (Run System - Database Verification first.).

afterChange() (the per-project alert scan) is called by the writes that can move health or breach a tolerance: RAID save and delete, tolerances, targets, milestones, task dates, budget lines, rates, currency, gate decisions and change-request decisions. Writes that cannot (members, scope, RACI, gate items, benefits) do not call it.

3.1 People: members, roles and the stakeholder map

Method Rules Audit
addMember($conn, $ctx, $pid, $in): int exactly one of analyst_id / team_id / user_id (the rule is here, not a CHECK); analyst must be active; team must exist; a person from People must be in the project's company (NULL read as Default); no duplicate of the same column and id (conflict, They are already on this project.); role_id must exist in project_roles or be empty; notes cut to 255; position = max + 1 member_added
updateMember($conn, $ctx, $pid, $memberId, $in): void only keys present change: role_id, notes, and (3.3.0, once stakeReady()) power and interest (1-5, empty = not placed), stance (one of STANCES), keep_informed (255) member_role when the role changed; stakeholder_saved when any stakeholder field was sent
removeMember($conn, $ctx, $pid, $memberId): void deletes the member's project_raci rows first, by hand, then the member member_removed
members($conn, $pid): array read: id, analyst_id, team_id, user_id, role_id, role_name, notes, position, power, interest, stance, keep_informed, name, kind (analyst / team / person), email, job_title; [] before Verification -
stakeReady($conn): bool probes the four stakeholder columns once per request; stakeColumns() selects NULL AS ... before them -
const STANCES = ['champion', 'supporter', 'neutral', 'sceptic', 'blocker'];

3.2 Scope items (MoSCoW)

Method Rules Audit
saveItem($conn, $ctx, $pid, $in): int create (no id) or update; title required, max 255; moscow one of MOSCOW or empty (case-insensitive in, lower-case stored); status one of ITEM_STATUSES (default proposed); stage_id must belong to the project (stageOf()); description and acceptance_criteria cut to 20,000; keys left out keep their stored value item_added; item_moscow when MoSCoW changed on an update
deleteItem($conn, $ctx, $pid, $itemId): void deletes its RACI rows, re-parents children (parent_id = NULL), then the item item_removed
moveItem($conn, $ctx, $pid, $itemId, $moscow, array $orderedIds): void the board's drag: sets moscow, then rewrites position 1..n for the ids given (only ids in this project are touched) item_moscow when the column changed
items($conn, $pid): array read with stage_name, by position, id -
const MOSCOW = ['must', 'should', 'could', 'wont'];
const ITEM_STATUSES = ['proposed', 'agreed', 'in_progress', 'accepted', 'dropped'];

3.3 RACI

setRaci($conn, $ctx, $pid, $itemId, $memberId, string $letter): array - one cell. The item and the member must both belong to the project. R, A, C, I (upper-cased) or '' to clear. Returns the whole row afterwards as {member_id: letter}, so the screen can redraw a demoted cell without a reload.

if ($letter === 'A') {
    // One accountable person per deliverable: the previous A keeps doing the work, as R.
    $conn->prepare("UPDATE project_raci SET letter = 'R' WHERE item_id = ? AND letter = 'A' AND member_id <> ?")->execute([$itemId, $memberId]);
}
$conn->prepare("INSERT INTO project_raci (project_id, item_id, member_id, letter) VALUES (?, ?, ?, ?)
                ON DUPLICATE KEY UPDATE letter = VALUES(letter)")->execute([$projectId, $itemId, $memberId, $letter]);

πŸ”‘ A second A demotes the first to R rather than refusing: the person clicking has just said who is accountable, and the previous one is still doing the work. A row with no A or no R is allowed (a draft); the screen flags it. RACI writes are not audited (they would flood the history); they touch the project. Read: raci($conn, $pid) β†’ {item_id: {member_id: letter}}.

3.4 The RAID log

const RAID_TYPES = ['risk', 'assumption', 'issue', 'dependency', 'decision', 'lesson'];   // dependency: 3.3.0
const RAID_RESPONSES = ['avoid', 'reduce', 'transfer', 'accept', 'share'];

saveRaid($conn, $ctx, $pid, $in): int - create or update. Every key left out keeps its stored value, so the REST PATCH needs no merge.

  • type required; title required, max 255.
  • Fields by type - other types store NULL whatever is sent: probability for risks only, impact for risks and issues (both 1-5), response for risks only (one of RAID_RESPONSES).
  • status open / closed; owner_analyst_id an active analyst; due_date a date.
  • ticket_id must pass projectLinkTargetOk($conn, $actor, 'ticket', $ticketId, $projectCompany) - the same rule as a Connections ticket link (page 8).
  • Decision log (3.3.0), written only once raidLogReady(): decided_by (VARCHAR 150 - a name, because the decider is often a sponsor with no analyst account), decided_date, rationale, for type = decision only (switching type away clears them). A future decided_date is refused; a decision saved closed with no date is stamped today.
  • Closing ends an escalation: when status is closed the same UPDATE clears escalated_datetime, escalated_by_id and escalation_note.
  • On update: closed_datetime stamped on open β†’ closed, cleared on β†’ open.
  • Audit: raid_added (type: title); raid_open / raid_closed on a status change; decision_made (title (decided_by)) when a decision closes.
  • Then touchProject() and afterChange().

The score is never stored: raid() computes it.

raid($conn, $pid): array - the read every door uses (page, export, REST, assistants):

SELECT r.*, a.full_name AS owner_name, t.ticket_number, t.subject AS ticket_subject,
       CASE WHEN r.type = 'risk' AND r.probability IS NOT NULL AND r.impact IS NOT NULL THEN r.probability * r.impact END AS score,
       ea.full_name AS escalated_by_name                        -- NULL before raidLogReady()
  FROM project_raid r ...
 ORDER BY r.status = 'closed', score IS NULL, score DESC, r.raised_datetime DESC

Open first, then by score. Each row gains actions (below), ticket_url, and - from a separate query, not a join, so the log still loads before the column is verified - article_url and article_published. Any failure returns [].

Escalation (3.3.0):

Method Rules
escalateRaid($conn, $ctx, $pid, $raidId, ?string $note): void raidLogReady() or not_ready; open entries only; a note is required (500) - Say what is needed, and from whom. Stamps escalated_datetime, escalated_by_id, escalation_note; audit raid_escalated; fires project.raid_escalated (ProjectsService::raidEscalated()). Escalating again replaces the note and fires again
deescalateRaid($conn, $ctx, $pid, $raidId): void no-op when not escalated; clears the three columns; audit raid_deescalated

Follow-up actions (3.3.0) - project_raid_tasks is a join (raid_id, task_id, unique pair), so tasks is untouched:

Method Rules
addRaidAction($conn, $ctx, $pid, $raidId, $in): int raidActionsReady() or not_ready; a title is required. The task is made by ProjectsService::createTaskInProject() with no stage, assigned_analyst_id and due_date if given, and the description Follow-up to the risk "…" in the project's RAID log. - so the assigned email, the bell and task events happen as for any task. Then the join row; audit raid_action_added. Returns the task id
removeRaidAction($conn, $ctx, $pid, $raidId, $taskId): void deletes the join only. The task stays - it is somebody's work
deleteRaid($conn, $ctx, $pid, $raidId): void deletes the joins by hand first (the tasks stay), then the entry; audit raid_removed; afterChange()
raidActionsReady($conn) / raidLogReady($conn) per-request probes of the join table and the escalation / decision columns

raid() returns each entry's actions as [{id, title, due_date, status_name, status_colour, is_closed, assignee_name}].

A lesson into Knowledge, an issue into a ticket. Both go through the other module's own service, so nothing is created behind its back, and both are once only.

  • lessonToKnowledge($conn, $ctx, $pid, $raidId): array β†’ ['id', 'url']. Lessons only; the column must exist (not_ready); conflict if already an article; needs the Knowledge module. Calls KnowledgeService::saveArticle() with is_published => false (a draft, as the Knowledge assistant makes), owner_id the actor, tenant_id the project's company on a multi-company install. The body is the lesson's description as <p> paragraphs (escaped) plus Learned on the project PRJ-0042 … linked with publicAbsoluteUrl(). Sets project_raid.knowledge_article_id, links the article on Connections, audits raid_to_knowledge.
  • issueToTicket($conn, $ctx, $pid, $raidId): array β†’ ['id', 'number', 'url']. Issues only; conflict if it already has a ticket; needs Tickets and a person (actorId > 0). The requester is the acting analyst's email - refused with a reason when missing or invalid (admin@localhost is invalid). TicketsService::createTicket() in the project's company, assigned to the issue's owner else the actor; description = detail + Impact: 4 - <word> + Raised from the RAID log of project PRJ-0042 …. It fires ticket.created, so workflows run as for any ticket. Sets project_raid.ticket_id, links the ticket, audits raid_ticket_raised (number title).
  • The Connections link is linkQuietly(): the record exists and is remembered on the entry, so a link that cannot be written is logged, never thrown.

3.5 Tolerances

saveTolerances($conn, $ctx, $pid, $in): void - project-level rows only (stage_id IS NULL). For each dimension present in $in: blank deletes the row; otherwise a whole number in range, upserted.

$rules = ['time' => [0, 365], 'risk' => [1, 25], 'cost' => [0, 500]];   // days late, risk score, % over budget

Audit tolerances (no values); afterChange(). Read: tolerances($conn, $pid) β†’ {time, risk, cost} with NULL for unset, never throws. What they do is on page 6.

3.6 Gates and gate checklists

const GATE_DECISIONS = ['go', 'go_with_conditions', 'stop'];

decideGate($conn, $ctx, $pid, $stageId, string $decision, ?string $notes): array β†’ ['closed' => bool, 'next' => ?string].

  1. The stage must belong to the project. A planned stage is refused (That stage has not started yet.) - a gate is the end of a stage, and closing one that never started would skip the one-active-stage rule.
  2. go_with_conditions needs notes.
  3. Checklist (3.3.0): for anything but stop, projectGateOpenItems(); open items throw checklist_open naming them, or - with project_gate_checklist = warn - are appended to the notes as Still open at the gate: ….
  4. One transaction: write gate_decision, gate_notes, gate_decided_by, gate_decided_datetime; then, only for a go on a stage that is not already closed, close it and set the next planned stage by position (then id) to active.
  5. After: audit gate (stage name β†’ decision); when it closed: syncCalendar() and stageClosed(); when it started the next stage: projectBaselineAuto(..., 'stage', $nextId); afterChange().
// Only a decision that CLOSES the stage hands over to the next one: a go
// recorded afterwards on a stage that is already finished must not start
// another stage while a later one is in progress.
if ($decision !== 'stop' && $stage['status'] !== 'closed') { ... }

decideGate() writes the stage directly, not through saveStage(); with the planned-stage refusal in step 1 it can only close the active stage (or record on a closed one), so the single_active rule holds.

Gate checklist items (3.3.0) - project_gate_items, rules in includes/projects/gatecheck.php:

Method Rules
saveGateItem($conn, $ctx, $pid, $in): int projectGateItemsReady(); on create stage_id must be a stage of the project and kind one of PROJECT_GATE_ITEM_KINDS (check / document / signoff / change - fixed after create); title required, max 200; analyst_id for a sign-off only (active analyst); change_id for a change only, and it must be linked to the project on Connections (projectGateChanges()); notes 500. Renaming a sign-off's person clears done_by_id / done_datetime (the old sign-off no longer counts) and asks the new one (signoffEvent()). Audit gate_item_added on create
deleteGateItem($conn, $ctx, $pid, $itemId): void audit gate_item_removed
tickGateItem($conn, $ctx, $pid, $itemId, $in): void loadForActor() only, then per kind: change - refused (done when the change is approved, in Changes); signoff - only the named analyst, and no change permission is needed (the sponsor signing off need not be on the team); document - assertCanChange(), document_id must be one of projectGateDocuments() (empty unticks); check - assertCanChange(). Audit gate_signed / gate_unsigned or gate_item_done / gate_item_reopened
setGateKind($conn, $ctx, $pid, $stageId, string $kind): int standard or golive. Going live adds every projectGoLiveStarter() item the gate does not already have by title (case-insensitive) through saveGateItem(), the sign-offs addressed to the project manager. Returns how many were added; audit gate_kind

3.7 Budget lines, labour rates and currency

Rules in includes/projects/budget.php (page 6). The writes:

Method Rules
saveBudgetLine($conn, $ctx, $pid, $in): int projectBudgetReady(); title required (200); category in PROJECT_BUDGET_CATEGORIES; planned, actual, forecast through projectMoney(); notes 500. (3.3.0) planned_date, spent_date (not in the future). A contract_id needs the Contracts module and the contract linked to the project (projectBudgetContracts()); a cost_centre_id must be active and in the project's company (projectBudgetCostCentres()), or already on this line (a cost centre switched off since stays). projectStampCurrency() first. A full replace, not a merge - the REST PATCH merges the stored line before calling it. Audit budget_line_added / budget_line_changed; afterChange()
budgetLineWhen() (private, 3.3.0) writes planned_date, spent_date, forecast_amount in their own UPDATE, so a line still saves before Verification adds them - unless one of them was actually sent, which is then not_ready
deleteBudgetLine($conn, $ctx, $pid, $lineId): void audit budget_line_removed
addProjectRate($conn, $ctx, $pid, $rate, ?string $from): void only when project_labour_mode = rate; a new dated row (scope = 'project'), never an update - the old rate still prices time logged before the new date; from defaults to today; projectLabourRatesReset(); audit labour_rate
deleteProjectRate($conn, $ctx, $pid, string $from): void removes the project's rate from that date; audit labour_rate_removed
setCurrency($conn, $ctx, $pid, string $code): void only with project_currency_per_project = 1; projectValidCurrency() (three letters); a relabel - nothing is converted; audit currency

Default and per-analyst rates are not here: they are api/projects/settings.php actions behind the Budget capability (Β§6).

3.8 Change control (3.3.0)

const CR_DECISIONS = ['approved', 'rejected'];
Method Rules
takeBaseline($conn, $ctx, $pid, $label): int changeable(); projectControlReady(); projectTakeBaseline(..., 'manual', $label); audit baseline_taken as Baseline 4: label
saveChangeRequest($conn, $ctx, $pid, $in): int create needs changeable(); edit is allowed to whoever raised it, or anyone who may change the project, and only while proposed. Title required (200); impact_days signed, up to 3650 either way; impact_cost through projectMoney(); impact_scope 1000; description and reason 20,000. number = max + 1 per project (CR-n). Audit change_raised / change_edited; create fires project.change_raised
decideChangeRequest($conn, $ctx, $pid, $crId, string $decision, $notes): array projectCanDecideChange($conn, $actor, $project, $raisedBy) (page 6); compare-and-set WHERE id = ? AND status = 'proposed'. Approving, with project_change_apply = plan: moves target_end_date by the days (audited as an ordinary target_end_date change) and inserts a budget line for the cost (category other), recording both in applied; then always projectTakeBaseline(..., 'change', null, null, $crId) and stores baseline_id. Returns ['baseline_id', 'applied']. Audit change_approved / change_rejected; syncCalendar() if the date moved; project.change_decided; afterChange()
withdrawChangeRequest($conn, $ctx, $pid, $crId): void the raiser or the team; proposed only; audit change_withdrawn

3.9 Benefits (3.3.0)

Method Rules
saveBenefit($conn, $ctx, $pid, $in): int projectBenefitsReady(); title required (200); baseline and target signed numbers, two decimals; direction up (default) or down; review_months 0-24, empty = project_benefit_review_months; status open / closed; owner an active analyst. A full replace like budget lines. A new benefit with no review_date gets projectBenefitNextReview(today, months). Audit benefit_added / benefit_changed
deleteBenefit($conn, $ctx, $pid, $benefitId): void measurements first (by hand), then the benefit; audit benefit_removed
addBenefitMeasure($conn, $ctx, $pid, $benefitId, $in): int a value (signed, two decimals), measured_date default today and never in the future, note 500. A measurement on an open benefit on or after review_date - PROJECT_BENEFIT_EARLY_DAYS (14) is the review: review_date moves on by review_months (NULL when 0). Audit benefit_measured
deleteBenefitMeasure($conn, $ctx, $pid, $benefitId, $measureId): void the measurement must belong to that benefit

3.10 Milestones, task dates, estimates and dependencies

The plan's writes - detailed with the reads and the JS on page 4:

Method One line
saveMilestone($conn, $ctx, $pid, $in): int (3.3.0) any field left out keeps its value; done true stamps done_date (or the date given) and who, false clears both; never reached in the future; audits milestone_added / _moved / _reached / _reopened; syncCalendar(); project.milestone_reached; afterChange()
deleteMilestone($conn, $ctx, $pid, $milestoneId): void (3.3.0) audit milestone_removed; syncCalendar(); afterChange()
setTaskDates($conn, $ctx, $pid, $taskId, $in): void (3.3.0) {start_date?, due_date?} through TasksService::saveTask(); the task must be in the project; Tasks access is not required
setTaskEstimate($conn, $ctx, $pid, $taskId, $hours): void (3.3.0) TasksService::saveTask(['id' => $taskId, 'estimate_hours' => $hours])
addTaskDependency($conn, $ctx, $pid, $taskId, $dependsOnId, $lag): int (3.3.0) both tasks in the project, not the same task, lag -365..365, no loop (projectDependencyMakesCycle()); upsert on the unique pair; audit dependency_added (waiter <- waited for)
removeTaskDependency($conn, $ctx, $pid, $depId): void (3.3.0) the dependency must be one of projectDependencies($pid); audit dependency_removed

3.11 Asset targets and announcing disruption

Method Rules
saveTarget($conn, $ctx, $pid, $in): int changeable() and assertAssets() (the rule names asset types and the live count would tell somebody without Assets what is in the estate); projectTargetsReady(); projectTargetNormalise(); the asset type must be one projectTargetOptions() offers the project's company. A changed rule deletes the target's snapshots - yesterday's points measured something else. Audit target_saved; afterChange()
deleteTarget() audit target_removed; afterChange()
assertAssets($conn, $ctx) / target($conn, $pid, $id) public: api/projects/tools.php uses both for the GET target reads
announce($conn, $ctx, $pid, $in): int project_disruption not off; Service Status access; a thin client of statusPlannedSave() with project_id; mode now passes the current time as the start, mode planned requires one. Audit disruption_announced. Returns the planned maintenance id
withdrawAnnouncement($conn, $ctx, $pid, $plannedId): void the plan must carry this project_id (else not_found); statusPlannedCancel(); audit disruption_withdrawn
announcements($conn, $analystId, $pid): ?array NULL without Service Status or before its table exists; else {mode, list, services, impacts}

Targets are explained on page 5; disruption on page 8.


4. ProjectReportsService and ProjectTemplatesService

Both are thin over their rules files; the detail is on pages 7 and 8.

ProjectReportsService (includes/services/project_reports.php, rules and AI on page 7):

Method Who
ready($conn), listFor($conn, $pid), latestBriefing($conn, $pid), schedule($conn, $pid), recipients($conn, $pid) reads
briefing($conn, $ctx, $pid, bool $refresh): array anyone who can open the project; cached BRIEFING_COOLDOWN_MINUTES (10); returns fresh
draftWithAi($conn, $ctx, $pid, $kind, $days = 14): int assertCanChange(); kind in KINDS (highlight, exception, checkpoint, closure); days clamped 1-90
save($conn, $ctx, $pid, $in): int assertCanChange(); an approved report cannot be changed; editing an AI draft sets ai_edited
approve($conn, $ctx, $pid, $id): void projectCanDelete(); an empty report cannot be approved
delete($conn, $ctx, $pid, $id): void a draft: assertCanChange(); approved: projectCanDelete()
setSchedule($conn, $ctx, $pid, $schedule, $kind): void (3.3.0) assertCanChange(); SCHEDULES off / weekly / fortnightly / monthly; kind highlight / checkpoint / exception
send($conn, $ctx, $pid, $id, array $emails, $note = ''): array (3.3.0) projectCanDelete(); approved only; at most SEND_MAX (50) addresses; ['sent' => [...], 'failed' => [...]]
runSchedules($conn, ?$pid), periodKey() from the alert scan, as the system

ProjectTemplatesService (includes/services/project_templates.php, format and built-ins on page 8):

Method Who
createFromTemplate($conn, $ctx, string $key, array $in, ?int $tenantId): int what creating a project needs - it goes through createProject() and createTaskInProject(); no outer transaction (TasksService opens its own), cleanup() on failure
saveFromProject($conn, $ctx, $pid, $in): int Cap::PROJECTS_TEMPLATES; parts filtered to plan, scope, raid, benefits, tolerances, targets; id replaces that saved template
update($conn, $ctx, $id, $in), delete($conn, $ctx, $id) Cap::PROJECTS_TEMPLATES; name required (150), description 500
setBuiltinHidden($conn, $ctx, string $key, bool $hidden) Cap::PROJECTS_TEMPLATES; the only writer of project_hidden_templates

4.3 The Ask AI project assistant (3.3.0)

Not a service class: includes/projects/assistant_chat.php holds it, and every change it makes goes through the services on this page. A proposal the person applies calls ProjectsService::updateProject(), saveStage() or createTaskInProject(), or ProjectToolsService::setTaskEstimate(), setTaskDates(), saveMilestone(), saveRaid(), saveItem(), addTaskDependency(), saveBudgetLine(), saveBenefit() or addMember() - with the person's own ActorContext, so assertCanChange(), validation, events and History are exactly as for the screens. The endpoint is api/projects/assistant_chat.php:

GET  ?project_id=N  -> {ready, ai_ready, can_set_up, can_change, shared, maturity, messages, remembers}
POST {action:'open'}                               greeting / catch-up / nothing
POST {action:'send', text}                         -> {messages, maturity}   (or {ai_error})
POST {action:'apply'|'dismiss', message_id, items:[0,2,...]}  -> {proposals, maturity}
POST {action:'clear'}
// assets/js/projects-assistant.js
const r = await P.api('assistant_chat.php', { project_id: ctx.projectId, action: 'send', text: 'What needs my attention?' });
// r.messages[r.messages.length - 1].proposals -> [{type:'task_dates', summary:'Move "Batch 3" to ...', status:'pending'}, ...]

The detail - the prompt, the tools, the memory, the apply order - is in part 7. The provider loop it uses, aiProviderChatTools() in includes/ai_provider.php, gained $opts['history'] and Azure support for it (#2247).

5. The endpoint bootstrap

Every file in api/projects/ starts with require_once __DIR__ . '/../../includes/projects/api_bootstrap.php';. It lives in includes/ so it cannot be requested on its own, and is kept in one place so a guard cannot be forgotten on the next endpoint (it mirrors includes/domains/api_bootstrap.php).

session_start(['read_and_close' => true]);
// config, functions, rbac, services/projects.php, projects/read.php
header('Content-Type: application/json');
if (!isset($_SESSION['analyst_id'])) { http_response_code(401); echo json_encode(['success' => false, 'error' => 'Not authenticated']); exit; }
requireModuleAccessJson('projects');

$conn      = connectToDatabase();
$analystId = (int)$_SESSION['analyst_id'];
$ctx       = ActorContext::fromSession($conn);    // source 'ui', the session's company scope
Helper What it does
projectApiBody(): array the decoded JSON body, cached; never null ([] for an empty or invalid body)
projectApiOk(array $data = []) echo json_encode(['success' => true] + $data); exit;
projectApiFail(string $message, int $status = 200) sets the status only when not 200; {success:false, error}; exits
projectApiRequirePost() 405 POST required. for anything but POST
projectApiTenantForCreate($conn, $analystId, $requested): int a requested company_id must exist and pass analystCanAccessTenant() (else You cannot add projects for that company.); otherwise the active company
projectApiRun(callable $fn) runs the endpoint; a ServiceError becomes projectApiFail($e->getMessage()); any other Throwable is logged with file and line and answered Something went wrong: …

Because projectApiOk() and projectApiFail() both exit, the switch statements in the endpoints have no break after them - each case ends the request.


6. Every endpoint in api/projects/

All need a session and access to the Projects module (from the bootstrap). Permissions beyond that are listed per endpoint; where it says "service", the rule is enforced in the service method named.

list.php - GET, the portfolio

Query: q (name, summary or goal), status (one of projectStatuses()), mine=1 (owner = me). The page loads once and filters in the browser; the filters are there for other callers. Runs projectAlertsOpportunistic() first (the no-cron fallback, at most every 15 minutes - page 7), then projectListRows() (company scope from activeTenantReadFilter(), members-only from projectVisibleSql()).

{
  "success": true,
  "projects": [
    {
      "id": 42, "code": "PRJ-0042", "tenant_id": null, "company_name": null,
      "name": "Leeds office move", "summary": "Move 60 staff to Wellington Place.", "goal": "Everyone working from the new floor by 30 November",
      "methodology": "staged", "status": "active", "health": "auto", "health_note": null, "priority": "high", "visibility": "everyone",
      "owner_analyst_id": 7, "owner_name": "Sam Patel", "start_date": "2026-09-01", "target_end_date": "2026-11-30", "actual_end_date": null,
      "colour": "teal", "icon": "building", "approval_status": null,
      "active_stage_name": "Fit-out", "stage_count": 4,
      "max_risk": 12, "tol_time": 10, "tol_risk": 15, "active_stage_end": "2026-10-24",
      "task_total": 38, "task_done": 21, "task_overdue": 2, "progress": 55,
      "tickets_7d": 0, "milestones_missed": 0, "next_milestone": {"id": 9, "name": "Move day", "due_date": "2026-11-21"},
      "raid_overdue": 0, "raid_escalated": 1, "changes_pending": 1, "benefits": 2, "benefits_due": 0, "ticket_spike": false,
      "auto_health": "amber", "exceptions": [], "shown_health": "amber",
      "tools": ["people", "scope", "raci", "raid", "gates", "budget", "control", "benefits"]
    }
  ],
  "multi_company": false,
  "can_create": true
}

can_create is projectCanCreate() - the page hides New when false.

get.php - GET ?id=, one project for its page

ProjectsService::loadForActor() (not found / not visible β†’ {success:false, error:"Project not found."}), then projectDetail() plus everything the tabs need. One call draws the whole page.

Key Source Notes
project projectDecorate() as a portfolio row, plus effort {estimate_hours, estimated_tasks, logged_hours, tasks} (3.3.0)
stages project_stages each with task_total, task_done (top-level tasks)
tasks top-level tasks with status, priority, assignee, team, subtask_count, estimate_hours, logged_minutes, completed_datetime, created_datetime, and from projectDependencyAnalysis(): depends_on, waiting_on, clash, critical, slack (3.3.0)
history project_audit newest 50
dependencies projectDependencies() [{id, task_id, depends_on_id, lag_days}] (3.3.0)
flow projectFlowSnapshot() then projectFlow() the status history chart; the snapshot is written on the way past (3.3.0)
permissions projectCanChange(), projectCanDelete() {can_change, can_delete} - the page hides what the server would refuse
members, items, raci, raid, tolerances ProjectToolsService reads raci is cast to an object so an empty one is {}, not []
targets projectTargetsDetail() upserts today's snapshot point
milestones projectMilestones() with state and met
can_assets module access
budget projectBudgetDetail() NULL before Verification
gate gatecheck {items (object keyed by stage id), documents, changes ([] without Changes), mode, me} (3.3.0)
benefits projectBenefits() (3.3.0)
proposal projectProposalDetail() NULL when it needed no approval and has no figures (3.3.0)
control projectControlDetail() baselines with their variance and change requests; NULL before Verification (3.3.0)
announcements ProjectToolsService::announcements() NULL without Service Status
gate_changes projectUnapprovedChanges() NULL (not []) without Changes access, so the gate stays silent rather than saying "none"
{
  "success": true,
  "project": { "id": 42, "code": "PRJ-0042", "name": "Leeds office move", "shown_health": "amber", "progress": 55,
               "effort": {"estimate_hours": 212.5, "estimated_tasks": 30, "logged_hours": 140.25, "tasks": 38} },
  "stages": [ {"id": 118, "kind": "stage", "name": "Fit-out", "status": "active", "start_date": "2026-09-28", "end_date": "2026-10-24",
               "position": 2, "gate_decision": null, "gate_kind": "standard", "task_total": "9", "task_done": "4"} ],
  "tasks": [ {"id": 5531, "title": "Patch panels labelled", "project_stage_id": 118, "start_date": "2026-10-19", "due_date": "2026-10-21",
              "is_closed": 0, "estimate_hours": "6.00", "logged_minutes": 90,
              "depends_on": [{"id": 5530, "lag": 0}], "waiting_on": [5530], "clash": true, "critical": true, "slack": 0} ],
  "dependencies": [ {"id": 14, "task_id": 5531, "depends_on_id": 5530, "lag_days": 0} ],
  "permissions": {"can_change": true, "can_delete": false},
  "members": [], "items": [], "raci": {}, "raid": [], "tolerances": {"time": 10, "risk": 15, "cost": 10},
  "milestones": [ {"id": 9, "stage_id": null, "name": "Move day", "due_date": "2026-11-21", "done_date": null, "state": "due", "met": null} ],
  "budget": null, "gate": {"items": {}, "documents": [], "changes": [], "mode": "block", "me": 7},
  "benefits": [], "proposal": null, "control": null, "announcements": null, "gate_changes": null,
  "targets": [], "can_assets": true, "flow": {"statuses": [], "points": [], "from": "2026-09-01"}, "history": []
}

save.php - POST, create or update a project

Body: ProjectsService::fieldMap() keys, plus id (update), company_id (create only - projectApiTenantForCreate(), then dropped), template (create only - builtin:<key> or saved:<id>, then dropped). With template on a new project it calls ProjectTemplatesService::createFromTemplate(); otherwise ProjectsService::saveProject(). Permissions: service (create policy / change policy; visibility needs the team or a manager).

// request
{"name": "Leeds office move", "methodology": "staged", "start_date": "2026-09-01", "target_end_date": "2026-11-30", "template": "builtin:office_move"}
// response
{"success": true, "id": 42, "created": true}

The edit dialog sends tailoring as {tool: bool} for the ticked boxes, except when the method was changed in the same save: then it sends null so the project takes the new method's set. The Gates tab saves business_case through this endpoint as an ordinary field.

The Toolbox (3.3.0, #2243 - assets/js/projects-toolbox.js, PrjToolbox.render()) has no endpoint of its own. The Overview lists the tools a project does not use yet, and Add writes the project's tailoring through this same save.php - exactly what the edit dialog's Tools boxes do, so a tool added there can be taken away in the dialog:

// assets/js/projects-toolbox.js
try { tail = JSON.parse(p.tailoring || '{}') || {}; } catch (e) { tail = {}; }
// tail[tool] = true, plus its NEEDS (raci adds people and scope)
await P.api('save.php', { id: ctx.projectId, tailoring: tail });

So it passes the change policy and validateField()'s tailoring rule like any other edit. Whether it is shown at all is lookups.php toolbox; Hide is per browser (localStorage freeitsm.projects.toolbox.hide.<id>).

delete.php - POST {id}

ProjectsService::deleteProject(). Permission: owner, creator or manager.

{"success": true, "id": 42, "tasks_detached": 38}

lookups.php - GET, what the forms draw from

Initialises I18n (the method labels and scale words are translated). Returns (lists shortened to one entry each; a template entry carries more keys than shown - see page 8):

{
  "success": true,
  "analysts": [{"id": 7, "full_name": "Sam Patel"}],
  "companies": [], "active_company": null, "multi_company": false,
  "default_method": "simple",
  "roles": [{"id": 1, "name": "Sponsor", "description": "Owns the business case"}],
  "teams": [{"id": 3, "name": "Infrastructure"}],
  "tools": {"people": {"label_key": "projects.tools.people", "desc_key": "projects.tools.people_desc"}},
  "probability_labels": ["Rare", "Unlikely", "Possible", "Likely", "Almost certain"],
  "impact_labels": ["Negligible", "Minor", "Moderate", "Major", "Severe"],
  "priorities": ["low", "medium", "high", "critical"], "toolbox": true,
  "priority_labels": ["Low", "Medium", "High", "Critical"],
  "stake_ready": true, "default_visibility": "everyone", "portfolio_sort": "target", "burnup_measure": "tasks",
  "templates": [{"key": "builtin:office_move", "name": "Office move"}],
  "can_manage_templates": false, "can_knowledge": true, "can_tickets": true,
  "methodologies": [{"key": "staged", "label": "Staged", "description": "...", "timebox": "stage"}],
  "statuses": ["proposed", "active", "on_hold", "closed", "cancelled"],
  "colours": [{"key": "coral", "from": "#f43f5e", "to": "#e11d48"}],
  "icons": ["rocket", "laptop", "building"],
  "task_statuses": [{"id": 1, "name": "To do", "colour": "#94a3b8", "is_closed": 0}],
  "task_priorities": [{"id": 2, "name": "Normal", "colour": "#64748b", "is_default": 1}]
}

companies lists only the companies the analyst can reach. default_visibility is NULL before Verification, which hides the form's field; stake_ready says whether the stakeholder columns exist (the People tab draws the map only then). toolbox (3.3.0, #2243) is the project_toolbox setting (General tab, default on): whether the Overview shows the Toolbox (below). templates is what the picker offers (built-ins not hidden, saved ones active); can_manage_templates shows the Template button.

stage_save.php, stage_delete.php, stage_reorder.php - POST

Endpoint Body Service Response
stage_save.php {project_id, id?, name, goal?, start_date?, end_date?, status?} ProjectsService::saveStage() {success, id}
stage_delete.php {project_id, id} ProjectsService::deleteStage() {success}
stage_reorder.php {project_id, ids: [...]} ProjectsService::reorderStages() {success}

stage_reorder.php exists but nothing in the UI calls it yet.

task_create.php, task_assign.php - POST

Endpoint Body Service
task_create.php {project_id, stage_id?, title, description?, assigned_analyst_id?, assigned_team_id?, start_date?, due_date?, priority_id?, status_id?} - only those task keys are passed on (array_intersect_key) ProjectsService::createTaskInProject() β†’ {success, id}
task_assign.php {task_id, project_id | null, stage_id?} ProjectsService::assignTask() β†’ {success}

Both on page 4 Β§3.

tools.php - GET reads and POST actions

See Β§7.

links.php - Connections, both directions

Method Parameters Returns
GET ?project_id=N {links: {kind: [...]}, ready} - a kind the analyst cannot use is left out
GET ?project_id=N&search=KIND&q= {results} - at most 20, already-linked rows excluded
GET ?for=KIND&id=N {projects} - the projects a record is linked to (its own page)
GET ?for=KIND&id=N&pick=1&q= {projects} - live projects it could be added to, that the analyst may change
POST {action: "add" | "remove", project_id, kind, target_id} {added} / {}

Every rule is in includes/projects/links.php (page 8). ready is projectLinksReady($conn) with no kind: true when any link table exists (the tab's run Database Verification note). Since #2242 the function takes an optional kind - projectLinksReady($conn, 'ticket') probes that one table, cached per kind per request - so a kind whose table is missing is left out on its own instead of the whole tab failing.

settings.php - Projects β†’ Settings

GET returns settings (every key with defaults applied; the three scales as their word lists), definitions ({key: {default, tab}}), can_write per tab, roles (with in_use counts), and - only to holders of the Budget capability - rates and analysts (rates are close to pay).

POST action Body Capability
save {tab, settings: {key: value}} the tab's: general β†’ Cap::PROJECTS_GENERAL, health β†’ PROJECTS_HEALTH, roles β†’ PROJECTS_ROLES, raid β†’ PROJECTS_RAID, budget β†’ PROJECTS_BUDGET (403 without)
role_save {id?, name, description?, is_active} Roles; name 1-100, unique
role_delete {id} Roles; members keep their place with no role (FK SET NULL)
role_reorder {ids: [...]} Roles
rate_save {scope: "default" | "analyst", analyst_id?, rate, from?} Budget; a new dated row
rate_delete {id} Budget; default and analyst rates only
rate_list - Budget

save refuses a key that does not belong to the tab (That setting does not belong on this tab: …) and validates each value with projectSettingValidate(). Saving project_calendar redraws the Calendar at once. No manifest tab declares setting_keys (except AI), on purpose - see page 1.

templates.php - Settings β†’ Templates

Everything needs Cap::PROJECTS_TEMPLATES (403 Looking after project templates needs the Templates permission). GET β†’ {templates (hidden and inactive too), ready}.

POST action Body Response
save_from_project {project_id, name, description?, parts: [...], id?} {id, key: "saved:N"}
update {id, name, description?, is_active} {templates}
delete {id} {templates}
builtin_hidden {key, hidden} (builtin: prefix optional) {templates}

Starting from a template is save.php with template.

reports.php - briefing and reports

GET ?project_id=N β†’ {ready, ai_ready, can_set_up, can_write, can_approve, briefing, reports, schedule, recipients} (recipients only for those who may approve). POST {action, project_id, ...}:

Action Body Service
briefing {refresh?} briefing() β†’ {briefing}
draft {kind, days?} draftWithAi() β†’ {id, reports}
save {id?, kind?, title, body} save() β†’ {id, reports}
approve {id} approve() β†’ {reports}
schedule {schedule, kind} setSchedule() β†’ {schedule} (3.3.0)
send {id, emails: [...], note?} send() β†’ {sent, failed, reports} (3.3.0)
delete {id} delete() β†’ {reports}

A provider problem is not a failure: projectReportsAiFail() answers {success: true, ai_error: "not_configured"} or {success: true, ai_error: "unreachable", detail}; a PDOException is rethrown. Page 7 has the rest.

capacity.php - GET ?weeks=4|8|12 (3.3.0)

Any other value becomes 4. {capacity: projectCapacity($conn, $analystId, $weeks)} - read-only; shape on page 4 Β§8.

portfolio_charts.php - GET (3.3.0)

For the projects projectListRows() gives this analyst:

{
  "success": true,
  "budgets": {"42": {"planned": 85000, "actual": 51230.5, "forecast": 88400, "currency": "GBP"}},
  "risks": [{"project_id": 42, "title": "Landlord delays access", "probability": 3, "impact": 4}],
  "milestones": [{"project_id": 42, "name": "Move day", "due_date": "2026-11-21", "state": "due"}]
}

Budgets only for projects with a budget or spend, never added across currencies; risks open with both scores; every milestone with its state.

export.php - GET, a spreadsheet (3.3.0)

Query Rows
what=portfolio&ids=3,1,7&format=xlsx|csv the portfolio's visible projects, in the page's order. The ids are intersected with projectListRows(), so editing the URL cannot widen the list; ids present but empty exports nothing (the header row only - the view shows nothing); no ids parameter at all = every visible project
what=raid&project_id=N&format=xlsx|csv one project's whole RAID log, open and closed (loadForActor() then ProjectToolsService::raid())

Written by includes/spreadsheet.php (every xlsx cell text, CSV formula-injection guard, UTF-8 BOM); headings are projects.export.*; file name projects-2026-10-09.xlsx or raid-PRJ-0042-2026-10-09.csv. A refusal is the usual JSON error, so the browser shows it instead of downloading.


7. api/projects/tools.php - every action

GET (needs project_id, loaded with loadForActor())

Query Needs Returns
people=Q - {people: [{id, name, email, job_title}]} - People matching name, email or job title, in the project's company (the same rule addMember() applies, so the picker never offers somebody it would refuse), at most 20
target_options=1 Assets {options} from projectTargetOptions()
target_preview=1&<rule fields> Assets {done, total} for a rule being edited
target_assets=ID&show=left|done Assets {assets} - at most 200, via projectTargetAssets()

Anything else is Unknown request.

POST {action, project_id, ...}

Every action is a case in one switch; each calls exactly one service method. "Changeable" = loadForActor() + assertCanChange().

Action Body (beyond project_id) Method Response Permission
member_add analyst_id | team_id | user_id, role_id?, notes? ProjectToolsService::addMember() {id} changeable
member_update member_id, role_id?, notes?, power?, interest?, stance?, keep_informed? updateMember() {} changeable
member_remove member_id removeMember() {} changeable
item_save id?, title, description?, acceptance_criteria?, moscow?, stage_id?, status? saveItem() {id} changeable
item_delete id deleteItem() {} changeable
item_move id, moscow, order: [ids in that column] moveItem() {} changeable
raci_set item_id, member_id, letter (R/A/C/I/'') setRaci() {row: {member_id: letter}} changeable
raid_save id?, type, title, description?, probability?, impact?, response?, response_plan?, owner_analyst_id?, status?, due_date?, ticket_id?, decided_by?, decided_date?, rationale? saveRaid() {id} changeable
raid_delete id deleteRaid() {} changeable
raid_to_knowledge id lessonToKnowledge() {article: {id, url}} changeable + Knowledge
raid_to_ticket id issueToTicket() {ticket: {id, number, url}} changeable + Tickets + a valid email
raid_escalate id, note escalateRaid() {} changeable (3.3.0)
raid_deescalate id deescalateRaid() {} changeable (3.3.0)
raid_action_add id, title, assigned_analyst_id?, due_date? addRaidAction() {task_id} changeable (3.3.0)
raid_action_remove id, task_id removeRaidAction() {} changeable (3.3.0)
tolerances_save time?, risk?, cost? (blank = remove) saveTolerances() {} changeable
gate_decide stage_id, decision, notes? decideGate() {closed, next} changeable
gate_item_save id?, stage_id, kind, title, analyst_id?, change_id?, notes? saveGateItem() {id} changeable (3.3.0)
gate_item_delete id deleteGateItem() {} changeable (3.3.0)
gate_item_tick id, done?, document_id?, notes? tickGateItem() {} per kind - a sign-off only its named analyst (3.3.0)
gate_kind stage_id, kind (standard/golive) setGateKind() {added} changeable (3.3.0)
target_save id?, name, scope, scope_type_id?, scope_field?, scope_value?, done_field, done_op, done_value, target_date? saveTarget() {id} changeable + Assets
target_delete id deleteTarget() {} changeable
announce title, comment?, start?, end?, services: [{service_id, impact_level_id}] announce() {id, announcements} changeable + Service Status + setting not off
announce_withdraw id withdrawAnnouncement() {announcements} changeable + Service Status
budget_line_save id?, title, category, planned?, actual?, contract_id?, cost_centre_id?, notes?, planned_date?, spent_date?, forecast? saveBudgetLine() {id} changeable (+ Contracts for a contract)
budget_line_delete id deleteBudgetLine() {} changeable
budget_rate_add rate, from? addProjectRate() {} changeable + labour mode rate
budget_rate_delete from deleteProjectRate() {} changeable
budget_currency currency setCurrency() {} changeable + per-project currency on
milestone_save id?, name?, due_date?, stage_id?, notes?, done?, done_date? saveMilestone() {id} changeable (3.3.0)
milestone_delete id deleteMilestone() {} changeable (3.3.0)
task_dates task_id, start_date?, due_date? setTaskDates() {} changeable (3.3.0)
task_estimate task_id, estimate_hours setTaskEstimate() {} changeable (3.3.0)
dep_add task_id, depends_on_id, lag_days? addTaskDependency() {id} changeable (3.3.0)
dep_remove id removeTaskDependency() {} changeable (3.3.0)
benefit_save id?, title, measure?, unit?, direction, baseline_value?, target_value?, target_date?, owner_analyst_id?, review_date?, review_months?, status, notes? saveBenefit() {id} changeable (3.3.0)
benefit_delete id deleteBenefit() {} changeable (3.3.0)
benefit_measure_add id (the benefit), value, measured_date?, note? addBenefitMeasure() {id} changeable (3.3.0)
benefit_measure_delete id (the benefit), measure_id deleteBenefitMeasure() {} changeable (3.3.0)
proposal_decide decision (approved/rejected), notes? ProjectsService::decideProposal() {status} the named approver or Manage Projects (3.3.0)
baseline_take label? takeBaseline() {id} changeable (3.3.0)
change_save id?, title, description?, reason?, impact_days?, impact_cost?, impact_scope? saveChangeRequest() {id} create changeable; edit the raiser or changeable (3.3.0)
change_decide id, decision, notes? decideChangeRequest() {baseline_id, applied} projectCanDecideChange() (3.3.0)
change_withdraw id withdrawChangeRequest() {} the raiser or changeable (3.3.0)

An unknown action is Unknown action. Example - a gate decision:

// POST api/projects/tools.php
{"action": "gate_decide", "project_id": 42, "stage_id": 118, "decision": "go_with_conditions", "notes": "Label the patch panels before move day"}
// 200
{"success": true, "closed": true, "next": "Move"}
// 200, a checklist item open and project_gate_checklist = block
{"success": false, "error": "1 checklist item(s) still open: Sponsor sign-off."}

And a RACI cell that demotes an earlier A:

// POST
{"action": "raci_set", "project_id": 42, "item_id": 301, "member_id": 17, "letter": "A"}
// 200 - member 12 was A, is now R
{"success": true, "row": {"12": "R", "17": "A", "21": "C"}}

8. Calling the endpoints from the page: P.api()

assets/js/projects.js exposes window.Prj (aliased P in every Projects script). Every URL is built from window.PRJ_API, which projects/includes/header.php sets from BASE_URL, never a root-relative /api/... (that 404s on an install in a sub-directory):

<script>window.PRJ_API = <?php echo json_encode(BASE_URL . 'api/projects/'); ?>; window.PRJ_BASE = <?php echo json_encode(BASE_URL); ?>; window.PRJ_ME = <?php echo (int)($_SESSION['analyst_id'] ?? 0); ?>;</script>
// assets/js/projects.js
async function api(path, body) {
    const opts = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
    let res;
    try {
        res = await fetch(window.PRJ_API + path, opts);
    } catch (e) {
        throw new Error('The server could not be reached.');
    }
    let data;
    try { data = await res.json(); } catch (e) { throw new Error('The server sent back something unexpected.'); }
    if (!data.success) throw new Error(data.error || 'Something went wrong.');
    return data;
}

No body means GET; a body means a JSON POST. The CSRF token is not mentioned anywhere in the module: assets/js/csrf.js, loaded first on every page, wraps fetch() and adds X-CSRF-Token to every same-origin non-GET request. A refusal becomes a thrown Error with the service's own sentence, which the callers show in a toast or the dialog's error line:

// assets/js/projects-timeline.js - adding a dependency from the dialog
async function depCall(body) {
    try { await P.api('tools.php', Object.assign({ project_id: ctx.projectId }, body)); await ctx.refresh(); openDeps(depTask.id); }
    catch (e) { const er = document.getElementById('pdpError'); er.textContent = e.message; er.hidden = false; }
}
depCall({ action: 'dep_add', task_id: depTask.id, depends_on_id: Number(pick), lag_days: document.getElementById('pdpLag').value });
// a GET with a query: the People picker (assets/js/projects-tools.js)
const d = await P.api('tools.php?project_id=' + ctx.projectId + '&people=' + encodeURIComponent(q));

P.lookups() caches lookups.php once per page. After any write the page calls refresh(), which reloads get.php and redraws every tab, so the ring, counts, plan and tools can never disagree. Callers outside the module (the Tasks window's Project field, assets/js/tasks.js) use plain fetch(APP_BASE + 'api/projects/...') with the same JSON shape.


9. REST API v1

api/v1/resources/projects.php, routed by api/v1/lib/routes.php. The usage guide with every field is REST API: Projects; this section is how it is built.

9.1 The routes

25 routes. A sub-resource write needs projects.update, because changing a stage, item, RAID entry or budget line is changing the project.

// api/v1/lib/routes.php
['GET',    '#^/projects$#',                            ['projects', 'read'],   'apiProjectsList'],
['POST',   '#^/projects$#',                            ['projects', 'create'], 'apiProjectsCreate'],
['GET',    '#^/projects/(\d+)$#',                      ['projects', 'read'],   'apiProjectsGet'],
['PATCH',  '#^/projects/(\d+)$#',                      ['projects', 'update'], 'apiProjectsUpdate'],
['DELETE', '#^/projects/(\d+)$#',                      ['projects', 'delete'], 'apiProjectsDelete'],
['GET',    '#^/projects/(\d+)/stages$#',               ['projects', 'read'],   'apiProjectStagesList'],
['POST',   '#^/projects/(\d+)/stages$#',               ['projects', 'update'], 'apiProjectStagesCreate'],
['PATCH',  '#^/projects/(\d+)/stages/(\d+)$#',         ['projects', 'update'], 'apiProjectStagesUpdate'],
['DELETE', '#^/projects/(\d+)/stages/(\d+)$#',         ['projects', 'update'], 'apiProjectStagesDelete'],
['POST',   '#^/projects/(\d+)/stages/(\d+)/gate$#',    ['projects', 'update'], 'apiProjectStagesGate'],
['GET',    '#^/projects/(\d+)/items$#',                ['projects', 'read'],   'apiProjectItemsList'],
['POST',   '#^/projects/(\d+)/items$#',                ['projects', 'update'], 'apiProjectItemsCreate'],
['PATCH',  '#^/projects/(\d+)/items/(\d+)$#',          ['projects', 'update'], 'apiProjectItemsUpdate'],
['DELETE', '#^/projects/(\d+)/items/(\d+)$#',          ['projects', 'update'], 'apiProjectItemsDelete'],
['GET',    '#^/projects/(\d+)/raid$#',                 ['projects', 'read'],   'apiProjectRaidList'],
['POST',   '#^/projects/(\d+)/raid$#',                 ['projects', 'update'], 'apiProjectRaidCreate'],
['PATCH',  '#^/projects/(\d+)/raid/(\d+)$#',           ['projects', 'update'], 'apiProjectRaidUpdate'],
['DELETE', '#^/projects/(\d+)/raid/(\d+)$#',           ['projects', 'update'], 'apiProjectRaidDelete'],
['GET',    '#^/projects/(\d+)/budget$#',               ['projects', 'read'],   'apiProjectBudget'],
['POST',   '#^/projects/(\d+)/budget-lines$#',         ['projects', 'update'], 'apiProjectBudgetLinesCreate'],
['PATCH',  '#^/projects/(\d+)/budget-lines/(\d+)$#',   ['projects', 'update'], 'apiProjectBudgetLinesUpdate'],
['DELETE', '#^/projects/(\d+)/budget-lines/(\d+)$#',   ['projects', 'update'], 'apiProjectBudgetLinesDelete'],
['GET',    '#^/projects/(\d+)/tasks$#',                ['projects', 'read'],   'apiProjectTasks'],
['GET',    '#^/projects/(\d+)/links$#',                ['projects', 'read'],   'apiProjectLinks'],
['GET',    '#^/projects/(\d+)/history$#',              ['projects', 'read'],   'apiProjectHistory'],

9.2 How the handlers are built

  • The key acts as its analyst. Every write builds ActorContext::fromApiKey($apiKey) (apiProjectCtx()): actorId = the key's analyst, companyScope = the key's (already narrowed to the analyst's by apiAuthenticate()), source = 'api'. So the module's create, change and delete rules apply unchanged, and a refusal is the service's own 403 with its own words.
  • apiProjectTry() runs the service call; a ServiceError becomes apiFailFromService() β†’ {"error": {"code": "...", "message": "..."}} with serviceErrorHttpStatus() (validation 422, bad_request 400, not_found 404, forbidden 403, conflict 409).
  • apiLoadProject($conn, $apiKey, $id) is the by-id gate: the row, apiKeyCanAccessTenantRow() and projectVisibleTo() (members-only, as the key's analyst) - any failure is 404, never 403. It then decorates the row the way the portfolio does (apiProjectDecorate(): projectTaskStats() + projectHealthConfig() + projectDecorate()), so health is the health the portfolio shows. Sub-resources are loaded through their project (apiProjectStage(), apiProjectItem(), apiProjectRaidRow(), apiProjectBudgetLine() look the child up among the project's own), so they share its scope.
  • apiProjectFields($body) keeps only fieldMap() keys plus project_manager_id, which is renamed to owner_analyst_id. Sub-resources have their own allow-lists (API_PROJECT_ITEM_FIELDS, API_PROJECT_RAID_FIELDS, the stage and budget-line lists).
  • The list filters status (comma list), methodology, project_manager_id, company_id (must be in the key's scope - 403 otherwise), q; sorts by name, target_end_date (default), start_date, created_at, updated_at, id (prefix - for descending, NULLs last); scopes with apiKeyTenantFilter() plus projectVisibleSql() as the key's analyst. ?health= is special: health is not a column, so the handler fetches up to 2000 rows, decorates, filters, then pages.
  • PATCH. Projects, stages, items and RAID entries are PATCH-friendly in the services (a key left out keeps its value). A budget line is a full replace in saveBudgetLine(), so apiProjectBudgetLinesUpdate() merges the stored line first (actual_typed, forecast_typed, the contract and cost centre ids).
  • Templates: POST /projects with template goes through ProjectTemplatesService::createFromTemplate().
  • The budget route returns 409 not_ready before Database Verification, and never one person's hourly rate.
  • Catalogue: the Projects section of api/v1/spec.json; schemas Project, ProjectStage, ProjectGateResult, ProjectItem, ProjectRaidEntry, ProjectBudget, ProjectBudgetLine, ProjectTaskSummary, ProjectLinks, ProjectHistoryEntry, ProjectDeleteResult in openapi_schemas.php, spliced as text. TRAP: never run api/v1/dev/openapi_fix.php in place - it rewrites that whole file with var_export and strips every comment. Run it on a copy and diff, or add fields by hand, then openapi_verify.php and openapi_check.php.

9.3 What is and is not in REST

In: projects (with priority, visibility, approval, estimated_cost, estimated_benefit), stages and gate decisions, scope items, the RAID log (3.3.0: dependency type, the decision log on write; escalation and actions read-only), the budget and its lines (3.3.0: planned_date, spent_date, forecast), tasks (read), links (read), history (read).

Not in REST yet: members and roles, RACI, tolerances, asset targets, templates as a resource, announcing disruption, milestones, dependencies, estimates (except through /tasks), capacity, escalating and RAID actions, gate checklists, benefits, change requests and baselines, deciding a proposal, reports and briefings.

9.4 curl

B="https://your-server/api/v1"; K="Authorization: Bearer fitsm_…"

# Live projects that are red, newest target first
curl -s "$B/projects?status=proposed,active&health=red&sort=-target_end_date" -H "$K"

# Create one, already planned from a built-in template, in a named company
curl -s -X POST "$B/projects" -H "$K" -H "Content-Type: application/json" -d '{
  "name": "Leeds office move", "template": "builtin:office_move", "company_id": 3,
  "start_date": "2026-11-02", "priority": "high", "project_manager_id": 7
}'

# Change only the target finish (history row carries source "api")
curl -s -X PATCH "$B/projects/42" -H "$K" -H "Content-Type: application/json" -d '{"target_end_date": "2026-12-11"}'

# Log a decision with its rationale (3.3.0)
curl -s -X POST "$B/projects/42/raid" -H "$K" -H "Content-Type: application/json" -d '{
  "type": "decision", "title": "Keep the old office until January", "status": "closed",
  "decided_by": "Finance Director", "decided_date": "2026-10-08", "rationale": "Lease break penalty is lower than double rent"
}'

# A gate decision
curl -s -X POST "$B/projects/42/stages/118/gate" -H "$K" -H "Content-Type: application/json" \
  -d '{"decision": "go_with_conditions", "notes": "Label the patch panels before move day"}'

# Correct a budget line's spend date only - the rest of the line is merged in
curl -s -X PATCH "$B/projects/42/budget-lines/77" -H "$K" -H "Content-Type: application/json" -d '{"spent_date": "2026-10-02"}'

A refusal from the module's own rules:

HTTP/1.1 403
{"error": {"code": "forbidden", "message": "Only this project's team, or someone who manages Projects, can change it."}}

A gate on a planned stage:

HTTP/1.1 422
{"error": {"code": "invalid_field", "message": "That stage has not started yet."}}

GET /projects/42 (shortened):

{
  "data": {
    "id": 42, "code": "PRJ-0042", "name": "Leeds office move", "company": null,
    "methodology": "staged", "status": "active", "health": "amber", "health_mode": "auto", "priority": "high",
    "visibility": "everyone", "approval": null,
    "project_manager": {"id": 7, "name": "Sam Patel"},
    "start_date": "2026-09-01", "target_end_date": "2026-11-30", "actual_end_date": null,
    "tools": ["people", "scope", "raci", "raid", "gates", "budget", "control", "benefits"],
    "progress": {"percent": 55, "tasks_total": 38, "tasks_done": 21, "tasks_overdue": 2},
    "active_stage": {"id": 118, "name": "Fit-out"},
    "exceptions": [],
    "budget": {"currency": "GBP", "planned": 85000.0, "actual": 51230.5, "forecast": 88400.0},
    "created_at": "2026-08-14T09:12:00Z", "updated_at": "2026-10-09T08:40:11Z", "closed_at": null
  }
}

10. MCP and Warbot: one file of answers

Both assistants answer questions about projects with the same text, from includes/projects/assistant.php (3.3.0). It was moved out of includes/mcp/tools.php when Warbot gained the same tools, so the two can never describe a project differently. Health, progress and exceptions come from the same projectDecorate() the screens use, so an assistant can never call a project green that its page calls red. Everything is read-only.

10.1 The shared functions

The caller passes a scope: [' AND p.tenant_id ...', [args]] for alias p, NULL company read as Default, already including the members-only clause.

Function Answers
projectAssistRows($conn, $scope, $where, $args, $limit) decorated rows (+ _budget), ordered active, proposed, on hold, closed, cancelled, then target date; limit capped at 500
projectAssistFind($conn, $scope, $ref) one project by PRJ-0042 / prj42 / 42, or a unique part of its name (an exact name wins among several); ServiceError missing_field, ambiguous (Several projects match "move": PRJ-0042 …, PRJ-0051 …. Use the code.) or not_found (No project "x" that you can see.)
projectAssistWhy($p) why it is the colour it is: set by hand, each exception, overdue tasks, missed milestones, late dependencies or decisions, escalations, change requests waiting, benefit reviews due, a ticket spike
projectAssistLine($p) PRJ-0042 Leeds office move [active, amber, high priority, 55% of 38 task(s) done, target 2026-11-30, led by Sam Patel] - 2 overdue task(s); 1 escalated
projectAssistList($conn, $scope, $args) status (live = proposed + active, the default; all; or one status), health, q, limit (1-100, default 25)
projectAssistOverview($conn, $scope, $analystId, $args, bool $withBudget) the line, goal, summary (600), method and dates, stages with gates, milestones (reached on time / late / MISSED / not reached yet), up to 10 open risks, issues, dependencies and decisions (LATE, ESCALATED), the budget only when $withBudget, unapproved linked changes only with Changes access, and the last 8 history rows filtered by module (below)
projectAssistRaid($conn, $scope, $args) type, status (open default, closed, all) - each entry with score, owner, due / needed by, plan, decision, escalation and "n of m follow-up actions done"
projectAssistTasks($conn, $scope, $args) top-level tasks, open_only default true, overdue first then by due date, at most 100
projectAssistDates($conn, $scope, $args) across every project in scope: open stage ends, target finishes and milestones in the next days (1-90, default 14), plus milestones already missed
projectAssistMoney($v, $cur) GBP 85,000.00

The history filter in projectAssistOverview() - the MCP security review's finding 6 - drops rows that name a record in a module the analyst cannot open, and the money rows when the budget is left out:

if (in_array($r['field_name'], ['link_added', 'link_removed'], true)) {
    $kind = strtok((string)$r['new_value'] ?: (string)$r['old_value'], ':');
    return projectLinkKindAllowed($conn, $analystId, (string)$kind);
}
if ($r['field_name'] === 'raid_ticket_raised') return analystCanAccessModule($conn, $analystId, 'tickets');
if ($r['field_name'] === 'raid_to_knowledge') return analystCanAccessModule($conn, $analystId, 'knowledge');
if ($r['field_name'] === 'disruption_announced' || $r['field_name'] === 'disruption_withdrawn') return analystCanAccessModule($conn, $analystId, 'service-status');
if (!$withBudget && (strpos($r['field_name'], 'budget_') === 0 || strpos($r['field_name'], 'labour_') === 0 || $r['field_name'] === 'currency')) return false;

Plain text out, - lists, no markdown: Warbot's chat shows text as typed, and an MCP client reads it either way.

10.2 The MCP server - includes/mcp/tools.php

api/mcp/index.php is the protocol; a key needs the mcp.read permission, and $apiKey['company_scope'] is replaced straight after authenticating with mcpEffectiveScope() - the key's companies intersected with its analyst's, so a key left on "all companies" still sees only what its analyst may. Every tool is declared with module (the analyst must be able to open it) and company_safe; the Projects tools are all company_safe because they scope themselves.

MCP tool Handler Shared function
list_projects mcpToolListProjects() projectAssistList()
project_overview mcpToolProjectOverview() projectAssistOverview(..., true) - with the budget
project_raid mcpToolProjectRaid() projectAssistRaid()
project_tasks mcpToolProjectTasks() projectAssistTasks()
project_dates mcpToolProjectDates() projectAssistDates()
project_budget mcpToolProjectBudget() MCP only: projectBudgetDetail() as text - totals, labour hours and cost (never a rate), each line, a contract in another currency marked not counted

The scope:

function mcpProjectScopeSql(PDO $conn, array $apiKey): array
{
    // Members-only projects (3.3.0): as the key's analyst.
    require_once __DIR__ . '/../projects/visibility.php';
    [$vSql, $vArgs] = projectVisibleSql($conn, (int)($apiKey['analyst_id'] ?? 0), 'p');
    [$sql, $args] = mcpProjectCompanySql($conn, $apiKey);   // ' AND (p.tenant_id IN (...) OR p.tenant_id IS NULL)' when Default is in scope
    return [$sql . $vSql, array_merge($args, $vArgs)];
}

A tool the key may not run is not listed (mcpToolsFor()), rather than listed and refused. mcpRunTool() returns a ServiceError's message as the tool's error text (isError: true) and anything else as That lookup failed. - never an SQL message, which would hand table and column names to the client.

Example exchange:

// tools/call
{"name": "project_overview", "arguments": {"project": "PRJ-0042"}}
// result content (text)
"PRJ-0042 Leeds office move [active, amber, high priority, 55% of 38 task(s) done, target 2026-11-30, led by Sam Patel] - 2 overdue task(s); 1 escalated\nGoal: Everyone working from the new floor by 30 November\nRuns as: staged. Start 2026-09-01, target finish 2026-11-30.\nStages:\n- Initiation (stage) closed, ends 2026-09-12, gate: go\n- Fit-out (stage) active, ends 2026-10-24\nMilestones:\n- Move day, due 2026-11-21: not reached yet\nBudget: GBP 85,000.00 planned, GBP 51,230.50 spent, GBP 88,400.00 forecast.\nRecent history:\n- 2026-10-08 Sam Patel raid escalated: risk: Landlord delays access"

10.3 Warbot - includes/warbot/tools.php (3.3.0)

The same five tools (no budget), each declared with 'module' => 'projects', so warbotToolAllowed() offers them only to somebody who can open Projects.

/** The asking analyst's projects: their active company, or every company they can see under "All". */
function warbotProjectScope(PDO $conn, int $analystId): array
{
    require_once __DIR__ . '/../tenancy.php';
    require_once __DIR__ . '/../projects/assistant.php';
    require_once __DIR__ . '/../projects/visibility.php';
    [$sql, $args] = activeTenantReadFilter($conn, $analystId, 'p');
    [$vSql, $vArgs] = projectVisibleSql($conn, $analystId, 'p');   // members-only (3.3.0)
    return [$sql . $vSql, array_merge($args, $vArgs)];
}
Warbot tool Handler Differs from MCP
list_projects warbotToolListProjects() default limit 10 (a channel answer)
project_overview warbotToolProjectOverview() $withBudget = false - a channel is read by everyone in it, and money is not an operational fact; budget history rows are dropped too
project_raid, project_tasks, project_dates warbotToolProjectRaid(), ...Tasks(), ...Dates() none

warbotProjectAnswer() turns a ServiceError (no such project, several match) into the sentence the room sees - it is an answer, not a failure. Re-run the MCP test after touching assistant.php: it covers the scope rules.


11. Adding an endpoint or an action

  1. The rule goes in a service. A new write is a method on ProjectsService or ProjectToolsService that starts with changeable() (or loadForActor() plus its own check), looks children up WHERE id = ? AND project_id = ?, throws ServiceError, and ends with ProjectsService::audit(), touchProject() and - if it can move health, a date or a tolerance - afterChange() and syncCalendar(). A new read catches a missing table and returns empty.
  2. The door only routes. A new case in api/projects/tools.php (or a new file that requires includes/projects/api_bootstrap.php, calls projectApiRequirePost() for a write and wraps everything in projectApiRun()). Update the doc comment at the top of tools.php.
  3. The page calls it through P.api(); hide what permissions says the server would refuse, but never rely on that.
  4. REST, if the thing belongs there: a route in api/v1/lib/routes.php, a handler in api/v1/resources/projects.php that loads through apiLoadProject(), calls the same service with apiProjectCtx() inside apiProjectTry(), and a serialiser; the spec and schemas by hand (never openapi_fix.php in place); REST API: Projects.
  5. Assistants, if it is something an assistant should be able to tell: a function in includes/projects/assistant.php taking a scope, then a tool in both includes/mcp/tools.php and includes/warbot/tools.php. Decide on purpose whether the answer may be read by a whole channel.
  6. History words under history.* in lang/en/projects.php, a Feature Bingo card, and the wiki.

See also: Projects Β· REST API: Projects Β· Service Layer Β· CSRF protection

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally