Skip to content

Projects Internals 6 Governance

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

Projects internals 6 - Governance

The parts of the Projects module that keep a project under control rather than just listing its work: the RACI matrix, the RAID log (scores and the heat map, dependencies, the decision log, escalation, follow-up actions, a lesson into Knowledge and an issue into a ticket), stage gates and what a gate decision changes, gate checklists, change control (baselines, variance and change requests), intake and approval of new projects (including the create_project workflow action), benefits realisation, and the budget (lines, contracts, cost centres, currency, dated labour rates, the forecast and the timeline). For each one this page gives the PHP functions with the code that matters, the settings that change its behaviour with their defaults, the JavaScript file that draws it and the api/projects/tools.php actions it calls. Health, tolerances, exceptions and the charts are on 5 Health and charts.

Pages in this series


1. The map

Every governance tool is a tool in projectToolDefinitions() (includes/projects/methodologies.php), so a project shows its tab only when the tool is on. The Staged method switches all of them on; Agile has raid; Simple has none of these. A project can tailor the set (projects.tailoring).

'staged' => [..., 'tools' => ['people', 'scope', 'raci', 'raid', 'gates', 'budget', 'control', 'benefits']],
'agile'  => [..., 'tools' => ['people', 'scope', 'raid']],
'simple' => [..., 'tools' => ['people']],

The Toolbox (3.3.0, #2243) is how a project gains governance tools as it grows. assets/js/projects-toolbox.js (PrjToolbox.render({data, L, projectId, refresh}), called from projects-view.js) lists on the Overview the tools a project does not use yet, in the order people, scope, raid, gates, budget, raci, control, benefits. Each one says what it is for and when you would want it, and has an Add button. Tools the project itself suggests are moved to the top; reason() works this out from data get.php already sent (for example "4 people on it - RACI says who does what"). Adding a tool writes the project's tailoring through api/projects/save.php, exactly as the Edit form's Tools boxes do, so it can be taken away there again. RACI needs People and Scope (NEEDS), so adding it adds those too. Ways to turn it off:

  • the whole install: Projects β†’ Settings β†’ General β†’ project_toolbox (bool, default 1; lookups toolbox);
  • one project, in one browser: Hide (localStorage freeitsm.projects.toolbox.hide.<id>).

⚠️ Tailoring is presentation, not permission. ProjectToolsService does not refuse a write to a tool that is switched off; the API still accepts it from someone allowed to change the project. The places where a tool being off changes behaviour are: exceptions (projectExceptions() returns nothing unless gates is on), automatic baselines (projectBaselineAuto() does nothing unless control is on) and the Budget tab and cost tolerance box (drawn only with budget on).

Area Rules (PHP) Writes Drawn by Tab / panel
RACI ProjectToolsService setRaci() assets/js/projects-tools.js renderRaci() #raci / pvRaci
RAID ProjectToolsService saveRaid() and friends projects-tools.js renderRaid(), heatMap() #raid / pvRaid
Stage gates ProjectToolsService::decideGate() decideGate(), saveTolerances() projects-tools.js renderGates() #gates / pvGates
Gate checklists includes/projects/gatecheck.php saveGateItem() and friends assets/js/projects-gatecheck.js (PrjGateCheck) inside each gate
Change control includes/projects/control.php takeBaseline(), saveChangeRequest(), decideChangeRequest(), withdrawChangeRequest() assets/js/projects-control.js (PrjControl) #control / pvControl
Intake includes/projects/intake.php ProjectsService::createProject(), decideProposal() assets/js/projects-intake.js (PrjIntake) Overview #pvProposal
Benefits includes/projects/benefits.php saveBenefit() and friends assets/js/projects-benefits.js (PrjBenefits) #benefits
Budget includes/projects/budget.php saveBudgetLine() and friends assets/js/projects-budget.js (PrjBudget) #budget / pvBudget

projects-view.js hands every module the same {data, projectId, refresh} after each load (PrjTools.render() also gets L - the lookups - and page), so nothing on the page can disagree. Every write goes to api/projects/tools.php (or api/projects/save.php for a project field), then calls refresh(). Every ProjectToolsService write starts with changeable() - ProjectsService::loadForActor() (company scope and members-only visibility; out of scope reads as not found) then assertCanChange() (projectCanChange(): the team, Manage Projects, or anyone when project_change_policy = anyone). The exceptions to that rule are named below where they apply (a sign-off, editing or withdrawing your own change request, deciding a change request or a proposal).


2. RACI

project_raci holds one letter per (deliverable, member) cell: project_id, item_id (a project_items row - scope), member_id (a project_members row), letter (R / A / C / I), with unique (item_id, member_id). It cascades with the item and with the member, and removeMember() and deleteItem() also delete their RACI rows by hand first, for installs whose foreign keys failed to add.

One A per row, by demotion - setRaci()

public static function setRaci(PDO $conn, ActorContext $ctx, int $projectId, int $itemId, int $memberId, string $letter): array
{
    self::changeable($conn, $ctx, $projectId);
    self::item($conn, $projectId, $itemId);
    self::member($conn, $projectId, $memberId);
    $letter = strtoupper(trim($letter));
    if ($letter !== '' && !in_array($letter, self::RACI, true)) throw new ServiceError('validation', 'invalid_field', 'Use R, A, C or I.');
    if ($letter === '') {
        $conn->prepare("DELETE FROM project_raci WHERE item_id = ? AND member_id = ?")->execute([$itemId, $memberId]);
    } else {
        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]);
    }
    ProjectsService::touchProject($conn, $projectId);
    ...   // returns the whole row: {member_id: letter}
}
  • πŸ”‘ Setting 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.
  • setRaci() returns the whole row afterwards (tools action raci_set β†’ {row}), so the screen redraws a demoted cell without a reload and shows a toast (raci.demoted).
  • An empty letter deletes the cell. A row with no A or no R is allowed (a draft); the screen flags it (raci.no_a / raci.no_r).
  • RACI cells are not written to the project history - only touchProject().

The screen - renderRaci() in assets/js/projects-tools.js

A table: deliverables (scope items, dropped ones left out) down the side, members across the top with their role. Each cell is a button that cycles '' β†’ R β†’ A β†’ C β†’ I β†’ '' (cycleRaci()), drawn optimistically and then replaced with the server's row. Cells are disabled without can_change. Each cell's aria-label reads "deliverable - person: Accountable". With no members or no items, it says which is missing.

RACI duties on People

peopleProjects() (includes/people.php) adds duties per project for a person - letter => deliverable titles, A then R, C, I (ORDER BY FIELD(x.letter, 'A', 'R', 'C', 'I'), i.position, i.id), dropped deliverables left out - and pplRaciDuties() (people/includes/render.php) prints one line per letter under the role, two titles and "and N more", the full list as the tooltip.


3. The RAID log

project_raid holds every entry; the type decides which fields mean anything. ProjectToolsService::RAID_TYPES = ['risk', 'assumption', 'issue', 'dependency', 'decision', 'lesson'], RAID_RESPONSES = ['avoid', 'reduce', 'transfer', 'accept', 'share'].

Field Types Notes
probability risk 1-5; any other type stores NULL whatever is sent
impact risk, issue 1-5
response, response_plan risk (response); any (response_plan) response is one of the five
owner_analyst_id any an active analyst
due_date any for a dependency it means "needed by", for a decision "decide by" (prDueLabel in the dialog changes its words)
ticket_id any (in practice issues) must pass projectLinkTargetOk($conn, $actor, 'ticket', ...) in the project's company - the same rule as a Connections ticket link
knowledge_article_id lesson set by lessonToKnowledge(); FK SET NULL
decided_by, decided_date, rationale (3.3.0) decision the decision log
escalated_datetime, escalated_by_id, escalation_note (3.3.0) any open entry escalation
status, closed_datetime any open / closed

The score and the heat map

πŸ”‘ The score is not stored. ProjectToolsService::raid() computes it in SQL and sorts by it:

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,
...
ORDER BY r.status = 'closed', score IS NULL, score DESC, r.raised_datetime DESC

projectExceptionColumns() computes max_risk the same way for the risk tolerance (see 5 Health and charts).

The heat map (heatMap() in projects-tools.js) is drawn from the rows, never kept separately: a 5 Γ— 5 grid of buttons, probability 5 at the top, impact 1-5 across, counting open risks with both values. Shading by scoreClass(): < 8 low (sc-low), 8-14 mid (sc-mid), >= 15 high (sc-high). Clicking a cell filters the list to that cell (raidFilter.cell); a second control clears it. The axis words come from api/projects/lookups.php (probability_labels, impact_labels), read through projectScaleLabels().

Risk scale words - Projects β†’ Settings β†’ RAID (Cap::PROJECTS_RAID): project_probability_labels and project_impact_labels, default empty = the translated defaults (projects.scale.* in the viewer's language). Rule labels5: a JSON array of exactly five non-blank words, each up to 40 characters; saving the defaults unchanged stores empty, so the scale keeps following each viewer's language. A risk stores only the step number, so the words are display only. projectScaleParse() also reads the comma format the first 3.2.0 builds wrote.

saveRaid() - create or update

$prob   = $type === 'risk' ? $scale(array_key_exists('probability', $in) ? $in['probability'] : ($cur['probability'] ?? null)) : null;
$impact = in_array($type, ['risk', 'issue'], true) ? $scale(array_key_exists('impact', $in) ? $in['impact'] : ($cur['impact'] ?? null)) : null;
$resp = $type === 'risk' ? (array_key_exists('response', $in) ? ($in['response'] ?: null) : ($cur['response'] ?? null)) : null;
  • An update keeps every field it is not given (array_key_exists against the current row), so the REST API's PATCH needs no merging.
  • A status change is audited raid_open / raid_closed and stamps or clears closed_datetime. A new entry is audited raid_added; a delete raid_removed.
  • Every save calls ProjectsService::afterChange(), because a risk can breach the risk tolerance.
  • The decision-log and escalation columns are written only when raidLogReady() says Database Verification has added them, so a RAID entry still saves on an unverified install.

Dependencies (3.3.0)

dependency joined RAID_TYPES: something the project needs from outside it - the landlord's fit-out, a supplier's delivery, another project. due_date is when it is needed by; closed means it arrived. It was added to the service, the templates' normalise and projects-tools.js; the plural key is raid.dependencies, so the JS uses raidPlural(), not t + 's'. Templates capture dependencies with risks and assumptions.

(A RAID dependency is not a task dependency. Task-to-task dependencies are task_dependencies - see 4 Plan, Timeline and dependencies.)

The decision log (3.3.0)

Columns decided_by (VARCHAR 150 - a name, because the person deciding is often a sponsor with no analyst account), decided_date and rationale. Written only for type = decision; switching an entry to another type clears them.

if ($on !== null && $on > gmdate('Y-m-d')) throw new ServiceError('validation', 'invalid_field', 'A decision cannot have been made in the future.');
if ($isDecision && $status === 'closed' && $on === null) $on = gmdate('Y-m-d');   // decided = closed; when, if nobody said

Closing a decision writes decision_made to the history (title plus the decider's name). The RAID tab's Decisions filter is the decision log: it shows each row's rationale inline (prj-raid-why) and a hint above the list.

Late dependencies and decisions turn health amber

projectTaskStats() counts raid_overdue - open dependencies and decisions with due_date before today - and projectAutoHealth() makes it amber (or red, or nothing) by project_health_raid_late (Health tab, off / amber / red, default amber). The RAID list marks such rows late (raidLate() in the JS), and the Overview shows a warning with the count (view.raid_late). See 5 Health and charts.

Escalation (3.3.0)

An open entry that needs somebody above the project manager - a sponsor, the board.

public static function escalateRaid(PDO $conn, ActorContext $ctx, int $projectId, int $raidId, ?string $note): void
{
    self::changeable($conn, $ctx, $projectId);
    if (!self::raidLogReady($conn)) throw new ServiceError('unavailable', 'not_ready', 'Run Database Verification first.');
    $r = self::raidRow($conn, $projectId, $raidId);
    if ($r['status'] !== 'open') throw new ServiceError('validation', 'invalid_field', 'Only an open entry can be escalated.');
    $note = self::str($note, 500);
    if ($note === null) throw new ServiceError('validation', 'missing_field', 'Say what is needed, and from whom.');
    ...
    ProjectsService::raidEscalated($conn, $projectId, self::raidRow($conn, $projectId, $raidId));
}
  • Stamps escalated_datetime, escalated_by_id and escalation_note (required, up to 500 characters); audited raid_escalated. Escalating again replaces the note and fires again.
  • deescalateRaid() clears the three (audited raid_deescalated); any save that closes the entry clears them too - there is nothing left to escalate.
  • Fires project.raid_escalated (ProjectsService::raidEscalated(), payload raid {id, type, title, due_date, owner_analyst_id} and note) - a bell to the project manager, on by default; rule 1 keeps it from the person who escalated. A workflow can email the sponsor.
  • The Overview lists every open escalated entry with who escalated it and when (escalated_by_name from raid()), and raid_escalated counts for the portfolio card.
  • Tools actions raid_escalate {id, note} and raid_deescalate {id}.

Follow-up actions (3.3.0)

project_raid_tasks (raid_id, task_id, unique pair, both FKs CASCADE) - a join, so tasks is untouched.

$taskId = ProjectsService::createTaskInProject($conn, $ctx, $projectId, null, array_filter([
    'title' => mb_substr($title, 0, 255),
    'assigned_analyst_id' => ...,
    'due_date' => self::date($in['due_date'] ?? null),
    'description' => 'Follow-up to the ' . $r['type'] . ' "' . $r['title'] . '" in the project\'s RAID log.',
], fn($v) => $v !== null));
$conn->prepare("INSERT INTO project_raid_tasks (raid_id, task_id, created_datetime) VALUES (?, ?, UTC_TIMESTAMP())")->execute([$raidId, $taskId]);
  • addRaidAction() makes a new project task through createTaskInProject() (so TasksService::saveTask() - the assigned email, the bell and task events happen as for any task), in no stage (where it sits in the plan is the Plan's job), then links it. Audited raid_action_added. Tools action raid_action_add {id, title, assigned_analyst_id?, due_date?} β†’ {task_id}.
  • removeRaidAction() and deleteRaid() unlink and never delete the task - it is somebody's work. Tools action raid_action_remove {id, task_id}.
  • deleteProject() and the template cleanup() delete the joins by hand first, for installs whose FKs failed.
  • raid() returns actions per entry: [{id, title, due_date, status_name, status_colour, is_closed, assignee_name}]; the list row shows "2 of 3 actions done".
  • raidActionsReady() keeps every read and write working before Database Verification.

A lesson into Knowledge, an issue into a ticket (3.2.0)

Both go through the other module's own service, so nothing is created behind its back.

lessonToKnowledge() (tools action raid_to_knowledge {id} β†’ {article: {id, url}}):

  • lessons only; once only (409 conflict after); needs the Knowledge module as well as the right to change the project; refused with "Run System β†’ Database Verification first" when knowledge_article_id does not exist yet;
  • KnowledgeService::saveArticle() with is_published => false - the same draft the Knowledge assistant makes - owned by the analyst, in the project's company on a multi-company install;
  • the body is the lesson's description as paragraphs (split on blank lines, each htmlspecialchars + nl2br) plus "Learned on the project PRJ-0042 Name" linking back through publicAbsoluteUrl();
  • sets project_raid.knowledge_article_id, links the article on Connections (linkQuietly()), audits raid_to_knowledge.

issueToTicket() (tools action raid_to_ticket {id} β†’ {ticket: {id, number, url}}):

  • issues only; once only; needs the Tickets module;
  • requester = the acting analyst's email - refused with a reason if it is missing or invalid (admin@localhost is invalid);
  • TicketsService::createTicket() in the project's company, assigned to the issue's owner, else the actor; description = the detail, "Impact: N - word", and "Raised from the RAID log of project PRJ-…";
  • sets project_raid.ticket_id, links the ticket on Connections, audits raid_ticket_raised. It fires ticket.created, so workflows run as for any ticket.

Shared rules:

  • linkQuietly(): the record exists and is remembered on the entry, so a Connections link that cannot be written is logged, not thrown.
  • projectLinkTargetOk() reads articles with lifecycle => 'unarchived' (as the analyst article list does): a draft can be linked. With the default 'live' the new draft was refused.
  • raid() returns article_url and article_published from a separate query, not a join, so the RAID log still loads on an install that has not verified the column. lookups.php returns can_knowledge / can_tickets so the buttons are offered only with the module.
  • The dialog saves the entry first (raidBody()), then acts, so typing is never lost.

The RAID tab - renderRaid()

The heat map beside the list; type filters with open counts; "Show closed"; the spreadsheet export (api/projects/export.php?what=raid&format=xlsx|csv, see 3 Services and API). The dialog #prjRaidModal shows fields by type (data-for lists the types each field belongs to). REST serialises escalation, decided_by, decided_date, rationale and actions, and accepts the three decision fields on create / PATCH; escalating and actions are not in the REST API yet.


4. Stage gates

A gate is the decision at the end of a stage: project_stages.gate_decision (go / go_with_conditions / stop), gate_notes, gate_decided_by (an analyst id, no FK), gate_decided_datetime, and (3.3.0) gate_kind (standard / golive). There is no separate gate approver: anybody who may change the project may decide.

decideGate() - what a decision changes

Tools action gate_decide {stage_id, decision, notes?} β†’ {closed, next}.

if (!in_array($decision, self::GATE_DECISIONS, true)) throw new ServiceError('validation', 'invalid_field', 'Choose go, go with conditions or stop.');
...
// A gate is the END of a stage: one that has not started has nothing to
// decide, and closing it here would skip the one-active-stage rule.
if ($stage['status'] === 'planned') throw new ServiceError('validation', 'invalid_field', 'That stage has not started yet.');
$notes = self::str($notes, 20000);
if ($decision === 'go_with_conditions' && !$notes) throw new ServiceError('validation', 'missing_field', 'Say what the conditions are.');
// The gate's checklist (3.3.0): an open item blocks a go, or is written into the notes (project_gate_checklist).
if ($decision !== 'stop') { ... }
$conn->beginTransaction();
...
    // 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') {
        $conn->prepare("UPDATE project_stages SET status = 'closed' WHERE id = ?")->execute([$stageId]);
        $closed = true;
        $n = $conn->prepare("SELECT id, name FROM project_stages WHERE project_id = ? AND status = 'planned' AND position > ? ORDER BY position, id LIMIT 1");
        ...  // start it: status = 'active'
    }
$conn->commit();
Decision On an active stage On a closed stage
go closes it and starts the next planned stage after it by position, in one transaction - the decision is the hand-over records the decision only
go_with_conditions the same; notes (the conditions) are required records only
stop records only - what happens next is the board's call, not the software's records only
  • πŸ”‘ A planned stage is refused ("That stage has not started yet."). Closing a stage that never started would skip the one-active-stage rule, and the API is held to the same rule as the screen.
  • decideGate() writes the gate straight to project_stages; it does not go through saveStage(), so the single_active check there is not involved. That is safe because only an active stage can close here and the stage it starts was planned, so a method with one active stage at a time still has one afterwards. The service itself refuses a planned stage, so the API cannot close one that never started while another stage is in progress.
  • The checklist (Β§5) is checked for go and go with conditions, never for stop.
  • After the transaction: audit gate (old = the stage name, new = the decision), touchProject(). When it closed the stage: ProjectsService::syncCalendar() (a closed stage leaves the Calendar) and ProjectsService::stageClosed() - project.stage_closed with gate_decision, next_stage and unapproved_changes (the count of linked changes not yet approved). When it started the next stage: projectBaselineAuto(..., 'stage', $nextId) (Β§6). Finally afterChange().

The Gates tab - renderGates() in projects-tools.js

  • The exceptions banner (exceptionsBanner()), the business case (a textarea saved through api/projects/save.php as an ordinary project field - not a tool method; projects.business_case, up to 50,000 characters, history shows "changed the business case" without the text), and the tolerances (time, risk, and cost when the budget tool is on - see 5 Health and charts).
  • One row per stage: kind, name, status pill, the decision with who and when, a chip when linked changes are unapproved on the active stage, the notes, and the checklist box [data-gc] that PrjGateCheck.fill() fills. Decide is offered on active and closed stages only.
  • The decide dialog #prjGateModal (openGate()): the three choices, the notes, and - for a stage not yet closed - the unapproved-changes warning (gateChangesBox()) and the open checklist items (PrjGateCheck.openBox()). After a go that closed the stage, P.celebrate() (skipped under prefers-reduced-motion) and a toast naming the next stage.

Unapproved changes at a gate (3.2.0, going live safely)

projectUnapprovedChanges() in includes/projects/links.php: changes linked to the project with approval_datetime IS NULL and a status that is not closed. The same "approved is a recorded fact" test Watchtower uses, but it keeps drafts (is_default status) and marks them - at a gate, an unsubmitted change is the bigger worry. Changes have no stage, so it is the project's whole list, ordered by work_start_datetime for the board to judge.

  • get.php returns it as gate_changes - null (not []) for an analyst without Changes access, so the gate stays silent rather than saying "none".
  • ProjectsService::stageClosed() adds the count as unapproved_changes.
  • πŸ”‘ Never a block: decideGate() does not read it. The board may know a change is a formality. A gate checklist change item (Β§5) is the way to make an approval required.

Announcing disruption (3.2.0, going live safely)

ProjectToolsService::announce() / withdrawAnnouncement() / announcements(), tools actions announce {title, comment?, start?, end?, services} and announce_withdraw {id}, and get.php announcements (null without Service Status, so no panel). It is a thin client of Service Status planned maintenance (includes/service_status_planned.php, see Service Status): status_planned.project_id is the link, not a project_* join table, because before its start there is no incident to link. The project_disruption setting (General, planned / now / off, default planned) only chooses the start: now passes the current time, so both modes are the same object; off hides the button and refuses. Withdraw checks the plan belongs to the project. deleteProject() nulls status_planned.project_id by hand - a table made by Database Verification has no FK. Announced disruption cannot be edited from the project (Service Status can, before it starts).


5. Gate checklists - includes/projects/gatecheck.php (3.3.0)

What must be true before a go. project_gate_items (project_id, stage_id, kind, title, analyst_id, change_id, document_id, done_by_id, done_datetime, notes, position, created_by_id); FKs CASCADE on project and stage, SET NULL on the analysts; also deleted by hand in deleteProject() and deleteStage(). project_stages.gate_kind is standard or golive.

The four kinds

PROJECT_GATE_ITEM_KINDS = ['check', 'document', 'signoff', 'change']

Kind Done when Who ticks it
check done_datetime is set anybody who may change the project
document document_id is still one of the project's attached documents (projectGateDocuments() - document_links with parent_type = 'project') anybody who may change the project, by choosing one of those documents; open again if that document is later taken off the project
signoff done_datetime is set only the named analyst (analyst_id) - no change permission needed
change the linked change's approval_datetime is set (projectGateChanges()) nobody - it is done when the change is approved, in Changes

πŸ”‘ Done is worked out on read in projectGateItems(), never trusted from a stored flag for documents and changes:

$done = match ($kind) {
    'document' => $docId !== null && isset($docs[$docId]),
    'change'   => $chgId !== null && !empty($changes[$chgId]['approved']),
    default    => $r['done_datetime'] !== null,
};

projectGateItems($conn, $projectId, $showChanges) returns items grouped by stage id; a change item's title is included only for an analyst who can open Changes (change_label - CHG-0042 - always). projectGateOpenItems($conn, $projectId, $stageId) returns [{id, kind, title}] of the items not done - what the decision reads.

What an open item does - project_gate_checklist

General tab, rule gatecheck, default block:

  • block - a go or go with conditions is refused with ServiceError('validation', 'checklist_open', 'N checklist item(s) still open: A; B.');
  • warn - the go is allowed and the notes gain Still open at the gate: A; B., so the record says so.
if ($decision !== 'stop') {
    require_once __DIR__ . '/../projects/gatecheck.php';
    $open = projectGateOpenItems($conn, $projectId, $stageId);
    if ($open) {
        $names = implode('; ', array_column($open, 'title'));
        if (projectSetting($conn, 'project_gate_checklist') !== 'warn') {
            throw new ServiceError('validation', 'checklist_open', count($open) . ' checklist item(s) still open: ' . $names . '.');
        }
        $notes = trim(($notes ? $notes . "\n\n" : '') . 'Still open at the gate: ' . $names . '.');
    }
}

A stop is never checked. A gate with no items behaves exactly as before 3.3.0. The decide box tells the person in advance which of the two will happen (open_block / open_warn).

Writes

Method Tools action Rule worth knowing
saveGateItem() gate_item_save {id?, stage_id, kind, title, analyst_id?, change_id?, notes?} title up to 200; a stage of this project; the kind cannot change on edit; a sign-off's analyst must be active; a change must be linked on Connections ("Link the change to the project on the Connections tab first."); notes up to 500. Renaming the signer clears the sign-off and asks the new one. Audit gate_item_added on create
deleteGateItem() gate_item_delete {id} audit gate_item_removed
tickGateItem() gate_item_tick {id, done?, document_id?, notes?} loadForActor() only, then per kind: sign-off - only analyst_id ("Only the person named can sign this off."), optional note; document - assertCanChange() and one of the project's documents, empty unticks; change - refused; check - assertCanChange(). Audit gate_signed / gate_unsigned / gate_item_done / gate_item_reopened
setGateKind() gate_kind {stage_id, kind} β†’ {added} golive adds the go-live starter items the gate does not already have by title (case-insensitive) - a gate can need several sign-offs; the sign-off goes to the project manager. Audit gate_kind

The go-live starter (projectGoLiveStarter()), in the viewer's language when projects.gatecheck.seed_* exists:

['signoff',  'User acceptance testing signed off'],
['document', 'Backout plan'],
['change',   'Change approved'],
['check',    'Support handover agreed'],

Every one is editable or removable afterwards.

The sign-off event

ProjectsService::signoffEvent() fires project.signoff_requested when a sign-off item gets a person (on create with an analyst, or when the signer changes), with item {id, title, analyst_id}, stage {id, name} and notify_ids = the signer. Stalled sign-offs are among the approvals the reminders nudge (includes/projects/nudges.php, see 7 Alerts, reports and AI).

Reads and screen

  • get.php gate: {items: {stage_id: [...]}, documents, changes (only with Changes access), mode (project_gate_checklist), me (the analyst's id)}.
  • assets/js/projects-gatecheck.js (window.PrjGateCheck = {fill, openBox}): fill(ctx) is called at the end of renderGates() and draws each [data-gc] box - "2 of 4 done", a go-live badge, the standard / go-live select and + Add for those who may change; one row per item with its control (a tick box, a document select, a Sign button shown only to the named analyst, a change select). openBox(stageId) returns the open items for the decide dialog. The item dialog is #prjGateItemModal (ids pgi*).
  • Documents: project is a parent in documentEntityRegistry() (includes/documents.php) - can = analystCanAccessProject() (includes/tenancy.php), filter = activeTenantFilter(). The Documents tab mounts the shared FreeITSMDocuments panel once per page load (docsMounted in projects-view.js), canEdit from can_change. Orphans are swept by documentsCollectOrphans() like every other parent.
  • Templates (#2239) carry stages[].gate_kind and gate_items [{kind, title}] - kind and title only; see 8 Connections, templates, people.

6. Change control - includes/projects/control.php (3.3.0)

Tool control, on for Staged. Tab #control, panel pvControl, drawn by assets/js/projects-control.js (PrjControl.render()); modals prjChangeModal, prjDecideModal, prjBaselineModal.

  • A baseline is the plan as it stood when it was agreed. It is never edited - a new one is taken instead, numbered 1, 2, 3 per project.
  • A change request asks to change the agreed plan and says what it does to time, cost and scope. It is proposed, then approved or rejected, or withdrawn. It is never deleted - the requests are the audit trail of why the plan moved.

The settings

Key Default Values Meaning
project_baseline_auto stage off / start / stage take a baseline by itself: never; when the project goes active; then and each time a stage starts
project_change_approver owner team / owner / managers who decides: anyone who may change the project; its project manager (or Manage Projects); Manage Projects only
project_change_self 1 bool may the person who raised a request decide it
project_change_apply plan baseline / plan approving only takes a new baseline, or also moves the target finish and adds the cost as a budget line

All on the General tab (Cap::PROJECTS_GENERAL).

Snapshots - projectPlanSnapshot()

πŸ”‘ One function builds the plan now in exactly the shape a baseline stores, so the same code makes a baseline and the right-hand side of every comparison.

Headline (columns) Detail (snapshot JSON)
start_date, target_end_date stages [{id, name, start_date, end_date}]
budget_planned (sum of lines' planned; null with none), currency milestones [{id, name, due_date}]
task_count (top-level tasks, as progress), estimate_hours must [{id, title}] - project_items.moscow = 'must', not dropped
must_count lines [{id, title, planned}] - budget lines

projectTakeBaseline($conn, $projectId, $actorId, $reason, $label, $stageId = null, $changeId = null) inserts the next number, reason (manual / start / stage / change), an optional label, the stage or change request that took it, the headline columns and the JSON.

Variance - projectBaselineVariance($base, $now)

Worked out on read for every baseline in projectControlDetail(); the JS only draws.

Key Value
start_days, finish_days whole days from the baseline's date to now's (projectDaysBetween(), UTC midnights); + = later
budget, budget_pct planned now minus planned then; the percentage when the baseline had a budget
tasks, hours, must differences in counts / estimated hours
milestones, stages projectVarianceItems(): `[{name, kind: moved
must_added, must_removed titles
lines budget lines whose planned amount moved, or that were added / removed

πŸ”‘ Items are matched by id, so a renamed milestone is the same milestone, not a removal plus an addition.

TRAP (by design): the variance is measured against the plan now, so a project manager who moved the target date by hand before a request was approved sees the drift on the old baseline straight away - which is the point.

Worked example. Baseline 1: target finish 2026-11-30, budget 20,000, 40 tasks, milestone "Go-live" (id 7) on 2026-11-20. Now: target 2026-12-14, budget 22,500, 44 tasks, "Go-live" renamed "Cut-over" and moved to 2026-12-04, a new milestone "Hypercare ends". Variance: finish_days = 14, budget = 2500, budget_pct = 13, tasks = 4, milestones = [{name: 'Cut-over', kind: 'moved', from: '2026-11-20', to: '2026-12-04', days: 14}, {name: 'Hypercare ends', kind: 'added', ...}].

Who decides - projectCanDecideChange()

function projectCanDecideChange(PDO $conn, int $analystId, array $project, ?int $raisedBy = null): bool
{
    if ($analystId <= 0) return false;
    if ($raisedBy !== null && $raisedBy === $analystId && projectSetting($conn, 'project_change_self') !== '1') return false;
    switch (projectSetting($conn, 'project_change_approver')) {
        case 'team':     return projectCanChange($conn, $analystId, $project);
        case 'managers': return projectIsManager($conn, $analystId);
        default:         return (int)($project['owner_analyst_id'] ?? 0) === $analystId || projectIsManager($conn, $analystId);
    }
}

projectControlDetail() sends can_decide and can_edit per request (and a page-level can_decide), so the page hides what the server would refuse; the server checks again. The nudges' change-request deciders are every active analyst projectCanDecideChange() accepts.

Writes

Method Tools action Permission What it does
takeBaseline() baseline_take {label?} changeable() reason = 'manual', label up to 150; audit baseline_taken ("Baseline 4: label")
saveChangeRequest() change_save {id?, title, description?, reason?, impact_days?, impact_cost?, impact_scope?} create: changeable(); edit: the raiser, or assertCanChange() - waiting requests only title up to 200; impact_days signed whole days up to 3650 either way; impact_cost signed money (projectMoney()); scope words up to 1000. Next number (CR-n). Audit change_raised / change_edited; create fires project.change_raised
decideChangeRequest() `change_decide {id, decision: approved rejected, notes?}β†’{baseline_id, applied}` loadForActor() + projectCanDecideChange()
withdrawChangeRequest() change_withdraw {id} the raiser, or assertCanChange() status = 'withdrawn'; audit change_withdrawn

Applying an approved change - decideChangeRequest()

// Compare-and-set: two people pressing Approve at once decide it once.
$u = $conn->prepare("UPDATE project_change_requests SET status = ?, decided_by_id = ?, decided_datetime = UTC_TIMESTAMP(), decision_notes = ?, updated_datetime = UTC_TIMESTAMP()
                      WHERE id = ? AND status = 'proposed'");
$u->execute([$decision, $ctx->actorId > 0 ? $ctx->actorId : null, $notes, $crId]);
if ($u->rowCount() !== 1) throw new ServiceError('validation', 'invalid_field', 'That request has already been decided.');
if ($decision === 'approved') {
    if (projectSetting($conn, 'project_change_apply') === 'plan') {
        // move target_end_date by impact_days (audited as an ordinary target_end_date change)
        // insert a budget line: category 'other', planned = impact_cost, title "CR-3: title",
        //   notes "Approved change request CR-3" (audited budget_line_added)
    }
    $baselineId = projectTakeBaseline($conn, $projectId, $ctx->actorId, 'change', null, null, $crId);
    $conn->prepare("UPDATE project_change_requests SET baseline_id = ?, applied = ? WHERE id = ?")
         ->execute([$baselineId, $applied ? json_encode($applied) : null, $crId]);
}
  • All in one transaction. The compare-and-set means two approvers decide once.
  • πŸ”‘ Approving always takes a new baseline - that is what approved means: the changed plan becomes the agreed one. Rejecting changes nothing.
  • With project_change_apply = plan: the target finish moves by impact_days (only when the project has one and the days are not 0), and a budget line is added for impact_cost (only when it is not 0 and the budget tables exist; the currency is stamped first). applied records it: {target_from, target_to, budget_line_id, budget_amount}; null when nothing was applied.
  • After: audit change_approved / change_rejected, syncCalendar() when the target moved, project.change_decided, afterChange() (a moved target or a new budget line can move health).
  • The decide dialog says in advance what approving will do (approveEffect(): the target from / to, the budget amount, "Baseline 5 will be taken").

Automatic baselines - projectBaselineAuto()

function projectBaselineAuto(PDO $conn, int $projectId, ?int $actorId, string $trigger, ?int $stageId = null): ?int

Quiet - it never throws, so it can never break the change that triggered it. It does nothing unless the tables exist, the setting allows the trigger (stage needs project_baseline_auto = stage; start needs start or stage), and the project has the control tool on. It takes one per stage (reason = 'stage' AND stage_id) and one for the start (reason = 'start'), and audits baseline_taken. Called from:

  • ProjectsService::updateProject() when status becomes active ('start');
  • ProjectsService::saveStage() when a stage becomes active, on edit or create ('stage');
  • ProjectToolsService::decideGate() when a go starts the next stage ('stage');
  • ProjectsService::decideProposal() when approving starts the project ('start', Β§7).

A project created active takes none - it has no agreed plan yet.

Events, reads elsewhere and history

  • project.change_raised and project.change_decided via ProjectsService::changeEvent(), payload change_request {id, number, reference CR-3, title, status, impact_days, impact_cost, impact_scope, raised_by_id, decided_by_id, decision_notes}. Both bell types are on by default; rule 1 keeps your own action off your bell; router titles CR-3 raised - ....
  • projectTaskStats() adds changes_pending (Overview line, portfolio chip, projectAssistWhy() for Warbot / MCP). projectAiFacts() adds the drift against the latest baseline and the requests waiting or decided in the period.
  • History values are stored in English like CR-3: title and Baseline 4: label; the page rewrites Baseline N into the viewer's language.
  • The Budget tab's spend chart draws the latest baseline's budget as a reference line when it differs from the budget now.
  • get.php control (null before Verification): {now, baselines: [{id, number, label, reason, stage_name, change_number, created_by_name, created_datetime, plan, variance}], requests, apply, approver, auto, can_decide}.
  • Demo baselines (#2240): the demo data ships baselines whose snapshot holds only a {"_demo": {...}} recipe (target_shift, stage_shift, budget_delta, task_delta). After the Tasks demo is imported, demoAfterImport() in includes/demo_data.php builds each baseline from projectPlanSnapshot() with those shifts applied. The demo projects' dates move with the import date, so this keeps the drift they show realistic. See 9 Demo data, testing and traps.

The tab - projects-control.js

The comparison panel: the latest baseline (or one picked from #ctlPick when there are several), who took it, when and why (reasonText()), a table of headline figures (then, now, delta - a bad delta marked) and what moved item by item. With no baseline yet it says when one will be taken (auto_<setting>). Below: the change requests, newest first, with "N waiting", a line saying who decides and what approving does, and per request Edit / Withdraw / Approve / Reject as can_edit / can_decide allow.


7. Intake and approval - includes/projects/intake.php (3.3.0)

A proposal is an ordinary project with status = 'proposed' and approval_status = 'pending'. The case for it is its business_case; its own figures are estimated_cost (DECIMAL 18,2) and estimated_benefit (TEXT, up to 5000); when it came from a form, form_submission_id (FK SET NULL), proposed_by_name and proposed_by_email say who asked. approval_by_id, approval_datetime and approval_notes record the decision.

estimated_cost and estimated_benefit are in ProjectsService::fieldMap() - estimated_cost with the type money (a two-decimal string, the same the database returns, so an unchanged value is not a change) - so the edit dialog, REST and history take them.

πŸ”‘ approval_status NULL means "needs none": every project from before 3.3.0, and every one created while the setting says it needs none. Turning the setting on later never strands an existing project.

The settings

Key Default Values Meaning
project_proposal_approval forms forms / all / off which new projects wait: those proposed on a form / every new one / none
project_proposal_approver managers managers / person who approves: Manage Projects, or a named analyst as well
project_proposal_approver_id 0 an analyst id the named analyst (rule analyst)
project_proposal_on_approve proposed proposed / active approving leaves it proposed for its project manager to start, or starts it

πŸ”‘ Whoever holds Manage Projects can always decide - a named approver who has left must not strand every proposal. projectProposalNamedApprover() only returns the named analyst while they are active.

function projectProposalNeedsApproval(PDO $conn, bool $fromForm): bool
{
    if (!projectIntakeReady($conn)) return false;
    $mode = projectSetting($conn, 'project_proposal_approval');
    return $mode === 'all' || ($mode === 'forms' && $fromForm);
}

function projectCanDecideProposal(PDO $conn, int $analystId): bool
{
    if ($analystId <= 0) return false;
    if (projectProposalNamedApprover($conn) === $analystId) return true;
    return projectIsManager($conn, $analystId);
}

Creating - ProjectsService::createProject()

require_once __DIR__ . '/../projects/intake.php';
$pending = projectProposalNeedsApproval($conn, !empty($in['_from_form']));
if ($pending) $in['status'] = 'proposed';
...
if ($pending) { $cols[] = 'approval_status'; $vals[] = 'pending'; }
...
// A proposal from a form is announced by intake.php once its proposer is stamped.
if ($pending && empty($in['_from_form'])) self::proposalEvent($conn, $id, 'project.proposal_submitted');

Pending forces status = 'proposed' whatever was asked for. Before Database Verification the estimated_* fields are skipped and nothing needs approval.

A pending proposal cannot start - projectProposalBlocksStatus()

function projectProposalBlocksStatus(array $project, string $newStatus): bool
{
    return ($project['approval_status'] ?? null) === 'pending' && !in_array($newStatus, ['proposed', 'cancelled'], true);
}

ProjectsService::updateProject() refuses any other status while pending: "This proposal is waiting for approval. It can start once it is approved." It may stay proposed or be withdrawn (cancelled). updateProject() also skips any fieldMap() field whose column the row lacks, so a save before Verification never names a missing column.

Deciding - ProjectsService::decideProposal()

Tools action proposal_decide {decision: approved|rejected, notes?} β†’ {status}.

public static function decideProposal(PDO $conn, ActorContext $ctx, int $projectId, string $decision, $notes): array
{
    require_once __DIR__ . '/../projects/intake.php';
    $p = self::loadForActor($conn, $ctx, $projectId);
    if (!in_array($decision, ['approved', 'rejected'], true)) throw new ServiceError('validation', 'invalid_field', 'Approve or reject.');
    if (($p['approval_status'] ?? null) !== 'pending') throw new ServiceError('validation', 'invalid_field', 'This project is not waiting for approval.');
    if (!projectCanDecideProposal($conn, $ctx->actorId)) throw new ServiceError('forbidden', 'forbidden', 'You may not approve or reject project proposals.');
    $notes = self::str($notes, 20000);
    if ($decision === 'rejected' && !$notes) throw new ServiceError('validation', 'missing_field', 'Say why it was rejected.');
    $start = $decision === 'approved' && projectSetting($conn, 'project_proposal_on_approve') === 'active';
    $newStatus = $decision === 'rejected' ? 'cancelled' : ($start ? 'active' : 'proposed');
    $u = $conn->prepare("UPDATE projects SET approval_status = ?, approval_by_id = ?, approval_datetime = UTC_TIMESTAMP(), approval_notes = ?, status = ?,
                                closed_datetime = " . ($newStatus === 'cancelled' ? 'UTC_TIMESTAMP()' : 'closed_datetime') . ", updated_datetime = UTC_TIMESTAMP()
                          WHERE id = ? AND approval_status = 'pending'");
    ...
}
  • Needs no change permission on the project - only projectCanDecideProposal() (and the project being reachable).
  • A reason is required to reject. Compare-and-set on approval_status = 'pending'.
  • Reject β†’ cancelled + closed_datetime. Approve β†’ stays proposed, or active with project_proposal_on_approve = active (then projectBaselineAuto('start')).
  • Audited proposal_approved / proposal_rejected (with the notes), plus a status row when the status changed. Then syncCalendar(), project.proposal_decided, afterChange().

Events

project.proposal_submitted and project.proposal_decided via ProjectsService::proposalEvent(). The payload is project plus proposal {status, estimated_cost, estimated_benefit, business_case, notes, decided_by_id, proposed_by_name, proposed_by_email, proposed_by_analyst_id, submission_id} - the email lets a workflow tell somebody who asked on a form and has no bell - and notify_ids:

  • submitted β†’ projectProposalApprovers(): the named approver if there is one, else every active analyst holding Manage Projects (admins included);
  • decided β†’ created_by_id, when that was an analyst.

notificationsAudienceFor() in includes/notifications_router.php uses notify_ids for every project.* event that carries one, because who approves is a Projects setting the router cannot know. Stalled proposals are nudged by includes/projects/nudges.php (see 7 Alerts, reports and AI).

The create_project workflow action

workflow/includes/engine.php registers create_project in availableActions() ("Create a project proposal") with these args: name, summary, business_case, estimated_cost, estimated_benefit, target_end_date (all text with variables), methodology and priority (select args with {value, label} options, blank = the default), and owner_analyst_id (an analyst lookup). action_create_project() collects them and calls projectCreateFromAction():

private static function action_create_project(array $args, array $payload): array
{
    $a = [];
    foreach (['name', 'summary', 'business_case', 'estimated_cost', 'estimated_benefit', 'target_end_date'] as $k) $a[$k] = self::argString($args, $k, $payload);
    foreach (['methodology', 'priority'] as $k) $a[$k] = (string)($args[$k] ?? '');
    $a['owner_analyst_id'] = self::argInt($args, 'owner_analyst_id', $payload);
    require_once __DIR__ . '/../../includes/projects/intake.php';
    return projectCreateFromAction(connectToDatabase(), $a, $payload);
}

projectCreateFromAction($conn, $a, $payload):

  1. The submission - when the payload has submission.id, it loads form_submissions with its form's title and catalogueSubmissionAnswers().
  2. Who asked, and for which company - a portal requester (submitted_by_user_id: their name, email and company), else an analyst (submitted_by β†’ created_by_id), else submission.email.
  3. Blanks are filled (the form-to-ticket rule - an override always wins, the submission fills the rest): a blank name becomes "Form title - requester"; a blank summary becomes the answers as label: value lines; a typed cost keeps only digits, . and - (so "Β£12,500" works), and anything still not numeric is left out rather than failing; methodology and priority are kept only when valid; a target_end_date only in YYYY-MM-DD.
  4. Runs as actor 0 - new ActorContext(0, null, 'ui', 'en', 'Workflow') - because a form is not somebody pressing New, so the create policy does not apply. _from_form is true when there was a submission, so project_proposal_approval = forms catches it.
  5. Stamps the proposer (form_submission_id, proposed_by_name, proposed_by_email, created_by_id = COALESCE(created_by_id, analyst)), then fires project.proposal_submitted itself when pending - createProject() ran before the proposer was known, so it held the event back.

Returns {project_id, code, name, approval}. The form designer (forms/edit/index.php faArgControl()) learned select args for this - {value, label} options, the workflow editor's shape.

Reads and screen

  • get.php proposal - projectProposalDetail(): null when the project needed no approval, has no figures and came from no form; otherwise {status, estimated_cost, estimated_benefit, decided_by_name, decided_datetime, notes, proposed_by (name, else email, else the creating analyst), proposed_by_email, form_title, submission_id, can_decide, on_approve}.
  • The portfolio row carries approval_status (projectProposalApprovalColumn() - NULL AS approval_status before Verification); the portfolio has an approval view (approval_status === 'pending') and a "waiting for approval" chip on the card.
  • REST approval; AI facts (WAITING FOR APPROVAL, the figures).
  • assets/js/projects-intake.js (PrjIntake.render()) fills #pvProposal on the Overview: the status pill, who proposed it and on which form, the cost, the benefit and the business case, the decision with who / when / notes, Approve / Reject for those who may decide (prjProposalModal - the intro says what approving will do from on_approve; notes required to reject) and Edit (prjProposalEditModal) for those who may change the project, which saves the case and figures through api/projects/save.php.

8. Benefits - includes/projects/benefits.php (3.3.0)

What a project is meant to improve, measured from a baseline towards a target, and reviewed on a date - including after the project has closed, which is when most benefits arrive. Tool benefits, on for Staged; tab #benefits drawn by assets/js/projects-benefits.js (PrjBenefits.render()); modals prjBenefitModal, prjBenefitMeasureModal.

  • project_benefits: title, measure (how it is measured), unit, direction (up = higher is better, down = lower is better), baseline_value, target_value, target_date, owner_analyst_id, review_date, review_months (NULL / 0 = no repeat), status (open = still reviewed, closed = stop reviewing), notes, position.
  • project_benefit_measures: benefit_id, value, measured_date, note, recorded_by_id. The latest by date is "now".
  • Both deleted with the project - by hand in deleteProject(), measurements first.

State - worked out, never stored

function projectBenefitState(array $b, ?float $now, ?string $today = null): array
{
    ...
    if ($now !== null && $base !== null && $target !== null && $target != $base) {
        $pct = (int)round(max(0, min(1.5, ($now - $base) / ($target - $base))) * 100);
    }
    $reached = $now !== null && $target !== null && ($up ? $now >= $target : $now <= $target);
    if (($b['status'] ?? 'open') === 'closed') $state = 'closed';
    elseif ($now === null) $state = 'not_measured';
    elseif ($reached) $state = 'achieved';
    elseif (!empty($b['target_date']) && $b['target_date'] < $today) $state = 'missed';
    else $state = 'in_progress';
    return ['state' => $state, 'progress' => $pct,
            'review_due' => ($b['status'] ?? 'open') === 'open' && !empty($b['review_date']) && $b['review_date'] <= $today];
}
State When
closed somebody stopped reviewing it
not_measured no measurement yet
achieved the latest value reaches the target in its direction
missed the target date has passed without reaching it
in_progress otherwise

progress = (now - baseline) / (target - baseline), clamped to 0-150%, so it works for both directions (a "down" benefit has target < baseline). Example: average ticket age, baseline 9 days, target 4, now 6, direction down - (6 - 9) / (4 - 9) = 0.6 β†’ 60%, in_progress; at 4 or below it is achieved.

projectBenefits($conn, $projectId) returns each benefit with its measurements, current and the state fields. projectBenefitStats() gives benefits / benefits_due per project to projectTaskStats() - for the Overview line, the card chip (on finished projects too) and the assistant.

The settings

Key Default Rule Meaning
project_benefit_review_months 3 int:0:24 how often a new benefit is reviewed (0 = once, no repeat)
project_benefit_notify both both / owner who the review reminder goes to: its owner and the project manager, or its owner only (the project manager when it has none)

Writes

Method Tools action Rule worth knowing
saveBenefit() benefit_save {id?, title, measure?, unit?, direction, baseline_value?, target_value?, target_date?, owner_analyst_id?, review_date?, review_months?, status, notes?} a full replace, like budget lines - every field is written; title up to 200; numbers up to two decimals; review_months 0-24, blank = the setting; a new benefit with no review date gets its first review review_months from today (projectBenefitNextReview()); owner an active analyst. Audit benefit_added / benefit_changed
deleteBenefit() benefit_delete {id} its measurements by hand first; audit benefit_removed
addBenefitMeasure() benefit_measure_add {id, value, measured_date?, note?} not in the future (blank = today); audit benefit_measured ("title: 6 days"). See below
deleteBenefitMeasure() benefit_measure_delete {id, measure_id} not audited

A measurement can be the review. One recorded on or after review_date - PROJECT_BENEFIT_EARLY_DAYS (14) on an open benefit moves the next review on by review_months from the later of the measurement and the review date - NULL when review_months is 0, so a one-off review ends:

$early = gmdate('Y-m-d', strtotime(($b['review_date'] ?: '9999-12-31') . ' 00:00:00 UTC') - PROJECT_BENEFIT_EARLY_DAYS * 86400);
if ($b['status'] === 'open' && (!$b['review_date'] || $date >= $early)) {
    $next = projectBenefitNextReview(max($date, (string)$b['review_date']), $b['review_months'] !== null ? (int)$b['review_months'] : 0);
    $conn->prepare("UPDATE project_benefits SET review_date = ?, updated_datetime = UTC_TIMESTAMP() WHERE id = ?")->execute([$next, $benefitId]);
}

Example: review due 2026-12-01, every 3 months. A measurement on 2026-11-20 (inside the 14 days) moves the review to 2027-03-01. One on 2026-10-01 leaves it alone.

Review reminders

projectAlertsBenefits($conn, ?$projectId), run by projectAlertsScan():

  • πŸ”‘ every project except cancelled - benefits outlive the project, so unlike the other alerts it looks at closed ones too;
  • open benefits whose review_date is today or in the last 30 days;
  • fire-once through workflow_scheduled_emissions with key project_benefit:<id>:review and the review date as the fingerprint, so a new date re-arms it; with no ledger table it stops rather than repeating every run;
  • project.benefit_review_due with benefit {id, title, measure, unit, review_date, baseline_value, target_value, owner_analyst_id} and notify_ids from projectBenefitNotifyIds().

TRAP: dispatched through projectAlertsAsSystem(). The scan also runs inside somebody's request (afterChange()), and the bell never tells you about your own action - so the project manager who happened to close the project would never have heard a review was due. A review falling due is nobody's action.

AI and templates

projectAiFacts() lists every benefit (baseline, now, target, state, direction, owner, next review / DUE); the report kind closure (projectAiReportKinds(), ProjectReportsService::KINDS, projects-reports.js) is built around them. Templates (#2239) carry benefits as their own part (benefits [{title, measure?, unit?, direction, target_value?, target_day?, review_months?}]) - see 8 Connections, templates, people.

The tab - projects-benefits.js

One card per benefit: the state pill, the measure and which way is better, baseline β†’ now β†’ target with a progress bar, the owner, the next review (marked when due), Record / Edit for those who may change the project, and the measurement history (opened per benefit, each with a delete). State and progress come from the server; the file only draws.


9. The budget - includes/projects/budget.php

Planned against actual, in the project's own currency, with labour worked out from the time logged on its tasks. Tool budget, on for Staged; tab #budget / pvBudget, drawn by assets/js/projects-budget.js (PrjBudget); line dialog prjBudgetModal. Arrived in 3.2.0; dates, the forecast, spend over time and earned value in 3.3.0.

πŸ”‘ Flexible, with no way to paint yourself into a corner. Every choice can be changed later without quietly rewriting what is already recorded - the header of budget.php says so, and every rule below follows from it.

Tables

  • project_budget_lines - title (200), category, planned_amount, actual_amount (DECIMAL 18,2), contract_id (FK SET NULL), cost_centre_id (FK SET NULL), notes (500), and 3.3.0's planned_date, spent_date, forecast_amount; position. Its three foreign keys (fk_pbl_project CASCADE, fk_pbl_contract and fk_pbl_cost_centre SET NULL) are in $projectFks in api/system/db_verify.php (#2243), along with project_reports' fk_prep_project. An upgraded install therefore gains them at Database Verification.
  • project_labour_rates - scope (default | project | analyst), ref_id (NULL for default, the project id, or the analyst id), hourly_rate (DECIMAL 12,2), effective_from. ref_id has no foreign key, because it points at a different table depending on scope. For that reason deleteProject() deletes the project's own rates (scope = 'project' AND ref_id = id) by hand (#2242).
  • projects.currency (CHAR 3).

PROJECT_BUDGET_CATEGORIES = ['hardware', 'software', 'services', 'labour', 'travel', 'other']. Labour is not a line - it is worked out from task_time_entries. A labour line is the plan for labour (see the forecast).

The settings - Budget tab (Cap::PROJECTS_BUDGET)

Key Default Rule Meaning
project_currency GBP three letters the currency new projects take
project_currency_per_project 0 bool may a project choose another
project_labour_mode hours hours / rate / analyst how labour is costed
project_cost_basis (3.3.0) actual actual / forecast what the cost tolerance measures
project_forecast_labour (3.3.0) 1 bool do the hours still estimated on open tasks count in the forecast

Default and analyst rates are managed on the same tab through api/projects/settings.php actions rate_save {scope: default|analyst, analyst_id?, rate, from?}, rate_delete {id} and rate_list - Budget capability only, and the GET returns rates and analysts only to holders of it, because rates are close to pay.

Currency - stored, never derived

/** Stamp the install currency on a project that has none, so a later change of default cannot relabel it. */
function projectStampCurrency(PDO $conn, int $projectId): void
{
    try {
        $conn->prepare("UPDATE projects SET currency = ? WHERE id = ? AND (currency IS NULL OR currency = '')")->execute([projectInstallCurrency($conn), $projectId]);
    } catch (Throwable $e) { /* before Database Verification */ }
}
  • projectStampCurrency() runs in createProject() and before any budget write (a line, a project rate, an approved change's cost). projectCurrencyOf() only falls back to the install default for a project that has never been stamped.
  • setCurrency() (tools budget_currency {currency}) is a relabel - amounts are not converted, and the page says so before anyone confirms; allowed only with project_currency_per_project = 1; audited currency.
  • πŸ”‘ Never add currencies. projectLineActual() uses a linked contract's value only when its currency matches (or is blank); otherwise the line is currency_mismatch and contributes nothing. Anything totalling across projects must group by currency - the portfolio's charts draw one budget chart per currency.

A line's actual and forecast

function projectLineActual(array $r, string $currency): array
{
    if ($r['actual_amount'] !== null && $r['actual_amount'] !== '') return [(float)$r['actual_amount'], 'typed', false];
    if (isset($r['contract_value']) && $r['contract_value'] !== null) {
        $cc = strtoupper((string)($r['contract_currency'] ?? ''));
        if ($cc === '' || $cc === $currency) return [(float)$r['contract_value'], 'contract', false];
        return [null, null, true];   // another currency: shown, never added
    }
    return [null, null, false];
}

/** A line's forecast: what was typed, else the larger of planned and actual; null when it has neither (3.3.0). */
function projectLineForecast(array $r, ?float $actual): ?float { ... return max($planned ?? 0.0, $actual ?? 0.0); }

function projectLineCoversLabour(array $r): bool
{
    return ($r['category'] ?? '') === 'labour' && ($r['actual_amount'] === null || $r['actual_amount'] === '');
}

A typed actual wins; otherwise a linked contract's value. A line's forecast is what somebody typed, else max(planned, actual) - money already spent is not a saving until the line is finished.

Labour - dated rates, never stored as money

projectLabour($conn, $projects, $byDay = false) prices each active task_time_entries row on a task whose project_id is this project with projectRateOn() - the newest rate whose effective_from is on or before the entry's date:

project_labour_mode Rate used
hours none - minutes only, cost null
rate the project's own rate (in the project's currency); else the default rate - only when the project is in the install currency
analyst only when the project is in the install currency: the analyst's rate, else the default

Time no rate in the right currency can price is unpriced_minutes, never converted. Returns per project {minutes, priced_minutes, cost, unpriced_minutes} plus days (priced cost per day) with $byDay. πŸ”‘ Labour is never stored as money: the mode is read on every view, so switching it re-reads; nothing migrates. Rates are cached per request in $GLOBALS['__prj_labour_rates']; projectLabourRatesReset() after a write.

⚠️ Subtasks never carry project_id (the Tasks code never sets it), so time logged on a subtask is not priced here and a subtask's estimate is not in labour to come - although the Plan's logged_minutes and project.effort (projectDetail()) do include subtasks' time. Log project time on the top-level task if it must be costed.

Example. Default rate 50 from 2026-01-01, 60 from 2026-10-01; mode rate; a GBP project in a GBP install with no rate of its own. 4 hours logged on 2026-09-30 and 4 on 2026-10-02 cost 4 Γ— 50 + 4 Γ— 60 = 440. Raising the rate today does not re-price September. The same project switched to EUR would show 8 unpriced hours until it is given a project rate.

The project's own rate: addProjectRate() (tools budget_rate_add {rate, from?}) - rate mode only, from defaults to today, a new row each time so the old rate still prices earlier time; audited labour_rate. deleteProjectRate() (budget_rate_delete {from}) removes one entered by mistake; audited labour_rate_removed. Both call afterChange().

Labour still to come (3.3.0)

projectLabourToCome($conn, $projects) - open tasks whose project_id is this project, with an estimate: estimate - that task's own logged minutes, floored at 0, priced at the rate in force today with the same rules (analyst mode uses the task's assigned_analyst_id). Tasks with no estimate are counted in open_unestimated. Returns {hours, cost, unpriced_hours, open_unestimated}. Off in the forecast with project_forecast_labour = 0 (the Budget tab still shows it, marked "not counted").

Totals - projectBudgetTotals()

For many projects at once - health, exceptions, reports, the portfolio charts, export, the AI facts.

foreach (projectLabour($conn, $projects) as $pid => $l) {
    $o = $out[$pid] ?? $blank(projectCurrencyOf($conn, $projects[$pid]));
    if ($l['cost'] !== null && $l['cost'] != 0) $o['actual'] += $l['cost'];
    $labour = (float)($l['cost'] ?? 0) + (float)($toCome[$pid]['cost'] ?? 0);
    $o['forecast'] += max($o['_cover'], $labour);
    $out[$pid] = $o;
}
$basis = projectSetting($conn, 'project_cost_basis') === 'forecast' ? 'forecast' : 'actual';
...
$o['basis'] = $basis;
$o['measured'] = $basis === 'forecast' ? $o['forecast'] : $o['actual'];

Returns per project {planned, actual, currency, has_budget, forecast, basis, measured}. πŸ”‘ Nothing is counted twice: a labour line with no typed actual is the plan for labour, so the forecast takes max(Ξ£ those lines, labour logged + still to come) - not both. measured is what the cost tolerance compares (see 5 Health and charts); basis flows to the cost exception, the alert payload, the router title (forecast 22% over budget), Warbot / MCP and the AI facts.

Worked example. Lines: hardware planned 5,000, actual 4,800; a labour line planned 3,000 with no actual. Labour logged costs 1,200; labour to come 2,500.

Working Result
Planned 5,000 + 3,000 8,000
Actual 4,800 + 1,200 labour 6,000
Forecast hardware max(5,000, 4,800) = 5,000, plus max(3,000 labour line, 1,200 + 2,500) = 3,700 8,700
Measured actual basis / forecast basis 6,000 / 8,700

With a 5% cost tolerance and project_cost_basis = forecast: 8,700 > 8,400 - a cost exception with over_pct = 8. With actual it is no breach.

Over time - projectBudgetTimeline() (3.3.0)

function projectBudgetTimeline(PDO $conn, array $project, array $lineRows, array $labourDays, float $planned, float $actual, float $forecast): array
  • Planned money counts on its planned_date, or from the project's start (start_date, else its creation date) when undated - undated counts those lines.
  • Actual money counts on its spent_date, else the day the line was last saved, else today; a currency-mismatched actual is left out. Labour counts on the day it was logged.
  • Points are every date anything moved plus the start and today, cumulative; actual is null after today.
  • forecast is {from: today, to: target, start: actual, end: forecast} - only when the target finish is after today and the forecast differs from actual; otherwise null.

The chart (PrjCharts.spend()), its reference lines and earned value (projectEarnedValue(), drawn with PrjCharts.lines()) are on 5 Health and charts.

projectBudgetDetail() - everything the tab shows

get.php returns it as budget (null before Verification): currency, install_currency, per_project_currency, labour_mode, labour, project_rates (rate mode only - the project's history, never anybody's personal rate), default_rate_now, lines (each with planned, actual_typed, actual, actual_source, currency_mismatch, planned_date, spent_date, forecast_typed, forecast, covers_labour, contract, cost_centre), planned, actual, remaining, forecast, labour_to_come (+ counted), cost_basis, timeline, earned, categories, cost_centres, and contracts (null without Contracts access).

Writes

Method Tools action Rule worth knowing
saveBudgetLine() budget_line_save {id?, title, category, planned?, actual?, planned_date?, spent_date?, forecast?, contract_id?, cost_centre_id?, notes?} a whole line (REST PATCH merges the stored line first). Amounts through projectMoney() (up to 14 digits, two decimals, may be negative); a spent date in the future is refused. A contract must be linked to the project on Connections and the actor must have Contracts. A cost centre must be active and in the project's company (projectBudgetCostCentres(), NULL company = Default) - or already on this line, so a switched-off one is kept. Stamps the currency. Audit budget_line_added / budget_line_changed; afterChange()
budgetLineWhen() (private) - writes planned_date, spent_date, forecast_amount on their own, so a line still saves before Verification adds them - refused only when one of the three was actually given
deleteBudgetLine() budget_line_delete {id} audit budget_line_removed; afterChange()
addProjectRate() / deleteProjectRate() budget_rate_add / budget_rate_delete above
setCurrency() budget_currency above

The tab - projects-budget.js

Tiles (planned, actual, forecast with "N over / under", remaining, used %), a used bar (warn at 90%, bad over 100%), a note when the tolerance measures the forecast, the three charts (earned value, spend over time, where the money goes - drawn only when the tab is visible; PrjBudget.shown() redraws), the currency line with Change when projects may choose, the lines table, labour (hours, cost, unpriced hours, labour to come), and in rate mode the project's rate history with an add box. PrjBudget.render() does nothing unless the budget tool is on.

REST: GET /projects/{id}/budget adds forecast, cost_basis, labour.to_come_hours, labour.to_come_cost; lines carry planned_date, spent_date, forecast, forecast_entered and accept the three on POST / PATCH; projects carry budget.forecast. Schemas are edited by hand in openapi_schemas.php - TRAP: api/v1/dev/openapi_fix.php rewrites that whole file with var_export and strips every comment in it; run it on a copy and diff, or add fields by hand, then openapi_verify.php + openapi_check.php.


10. Every governance setting in one place

From projectSettingDefinitions() in includes/projects/settings.php. Every key is saved only through api/projects/settings.php, which validates it with projectSettingValidate() and checks the tab's capability.

Key Tab Default Values
project_change_policy General team team / anyone - who may change a project (and so write every tool)
project_disruption General planned planned / now / off
project_baseline_auto General stage off / start / stage
project_change_approver General owner team / owner / managers
project_change_self General 1 bool
project_change_apply General plan baseline / plan
project_toolbox General 1 bool - the Overview's Toolbox for adding tools (#2243)
project_proposal_approval General forms forms / all / off
project_proposal_approver General managers managers / person
project_proposal_approver_id General 0 an analyst id
project_proposal_on_approve General proposed proposed / active
project_benefit_review_months General 3 0-24
project_benefit_notify General both both / owner
project_gate_checklist General block block / warn
project_nudge_days General 3 0-30 - stalled proposals, change requests, sign-offs and report drafts are nudged after this many days and every as many again (0 = never)
project_health_raid_late Health amber off / amber / red
project_probability_labels, project_impact_labels RAID empty five words each
project_currency Budget GBP three letters
project_currency_per_project Budget 0 bool
project_labour_mode Budget hours hours / rate / analyst
project_cost_basis Budget actual actual / forecast
project_forecast_labour Budget 1 bool

11. Not built yet

  • No per-stage tolerances (the stage_id column exists) and no per-stage budgets.
  • Budget lines are not in templates; budget figures are not in Report Packs blocks; no forecast per category on the bar chart.
  • Change requests and baselines are not in the REST API or the MCP server; a change request cannot name the scope items it adds or drops (scope is words); drift from the baseline does not affect health (the time and cost tolerances still measure against the plan now).
  • Proposals cannot be decided through the REST API or MCP, and have one named approver at a time.
  • Benefits are not in the REST API, MCP or Report Packs, and their owner must be an analyst.
  • Gate checklists are not in REST or MCP, and a sign-off must be by an analyst.
  • RAID escalation and follow-up actions are not in the REST API (read only), and a task does not show which RAID entry it is an action of.
  • Announced disruption cannot be edited from the project.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally