Repository navigation
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 Architecture
- 2 Schema
- 3 Services and API
- 4 Plan, Timeline and dependencies
- 5 Health and charts
- 6 Governance
- 7 Alerts, reports and AI
- 8 Connections, templates, people
- 9 Demo data, testing and traps
- 10 Contractors
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, default1; lookupstoolbox); - one project, in one browser: Hide (localStorage
freeitsm.projects.toolbox.hide.<id>).
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).
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.
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 actionraci_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().
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.
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.
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 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 DESCprojectExceptionColumns() 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.
$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_existsagainst the current row), so the REST API's PATCH needs no merging. - A status change is audited
raid_open/raid_closedand stamps or clearsclosed_datetime. A new entry is auditedraid_added; a deleteraid_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.
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.)
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 saidClosing 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.
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.
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_idandescalation_note(required, up to 500 characters); auditedraid_escalated. Escalating again replaces the note and fires again. -
deescalateRaid()clears the three (auditedraid_deescalated); any save that closes the entry clears them too - there is nothing left to escalate. - Fires
project.raid_escalated(ProjectsService::raidEscalated(), payloadraid{id, type, title, due_date, owner_analyst_id} andnote) - 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_namefromraid()), andraid_escalatedcounts for the portfolio card. - Tools actions
raid_escalate {id, note}andraid_deescalate {id}.
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 throughcreateTaskInProject()(soTasksService::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. Auditedraid_action_added. Tools actionraid_action_add {id, title, assigned_analyst_id?, due_date?}β{task_id}. -
removeRaidAction()anddeleteRaid()unlink and never delete the task - it is somebody's work. Tools actionraid_action_remove {id, task_id}. -
deleteProject()and the templatecleanup()delete the joins by hand first, for installs whose FKs failed. -
raid()returnsactionsper 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.
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 conflictafter); needs the Knowledge module as well as the right to change the project; refused with "Run System β Database Verification first" whenknowledge_article_iddoes not exist yet; -
KnowledgeService::saveArticle()withis_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 throughpublicAbsoluteUrl(); - sets
project_raid.knowledge_article_id, links the article on Connections (linkQuietly()), auditsraid_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@localhostis 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, auditsraid_ticket_raised. It firesticket.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 withlifecycle => 'unarchived'(as the analyst article list does): a draft can be linked. With the default'live'the new draft was refused. -
raid()returnsarticle_urlandarticle_publishedfrom a separate query, not a join, so the RAID log still loads on an install that has not verified the column.lookups.phpreturnscan_knowledge/can_ticketsso the buttons are offered only with the module. - The dialog saves the entry first (
raidBody()), then acts, so typing is never lost.
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.
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.
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 toproject_stages; it does not go throughsaveStage(), so thesingle_activecheck 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) andProjectsService::stageClosed()-project.stage_closedwithgate_decision,next_stageandunapproved_changes(the count of linked changes not yet approved). When it started the next stage:projectBaselineAuto(..., 'stage', $nextId)(Β§6). FinallyafterChange().
- The exceptions banner (
exceptionsBanner()), the business case (a textarea saved throughapi/projects/save.phpas 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 thebudgettool 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]thatPrjGateCheck.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 underprefers-reduced-motion) and a toast naming the next stage.
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.phpreturns it asgate_changes- null (not[]) for an analyst without Changes access, so the gate stays silent rather than saying "none". -
ProjectsService::stageClosed()adds the count asunapproved_changes. - π Never a block:
decideGate()does not read it. The board may know a change is a formality. A gate checklistchangeitem (Β§5) is the way to make an approval required.
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).
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.
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.
General tab, rule gatecheck, default block:
-
block- a go or go with conditions is refused withServiceError('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).
| 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.
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).
-
get.phpgate:{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 ofrenderGates()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(idspgi*). - Documents:
projectis a parent indocumentEntityRegistry()(includes/documents.php) -can=analystCanAccessProject()(includes/tenancy.php),filter=activeTenantFilter(). The Documents tab mounts the sharedFreeITSMDocumentspanel once per page load (docsMountedinprojects-view.js),canEditfromcan_change. Orphans are swept bydocumentsCollectOrphans()like every other parent. - Templates (#2239) carry
stages[].gate_kindandgate_items [{kind, title}]- kind and title only; see 8 Connections, templates, people.
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.
| 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).
π 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.
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', ...}].
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.
| 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
|
// 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 byimpact_days(only when the project has one and the days are not 0), and a budget line is added forimpact_cost(only when it is not 0 and the budget tables exist; the currency is stamped first).appliedrecords 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").
function projectBaselineAuto(PDO $conn, int $projectId, ?int $actorId, string $trigger, ?int $stageId = null): ?intQuiet - 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 becomesactive('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.
-
project.change_raisedandproject.change_decidedviaProjectsService::changeEvent(), payloadchange_request{id, number, referenceCR-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()addschanges_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: titleandBaseline 4: label; the page rewritesBaseline Ninto 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.phpcontrol(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
snapshotholds only a{"_demo": {...}}recipe (target_shift,stage_shift,budget_delta,task_delta). After the Tasks demo is imported,demoAfterImport()inincludes/demo_data.phpbuilds each baseline fromprojectPlanSnapshot()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 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.
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.
| 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);
}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.
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.
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 β staysproposed, oractivewithproject_proposal_on_approve = active(thenprojectBaselineAuto('start')). - Audited
proposal_approved/proposal_rejected(with the notes), plus astatusrow when the status changed. ThensyncCalendar(),project.proposal_decided,afterChange().
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).
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):
-
The submission - when the payload has
submission.id, it loadsform_submissionswith its form's title andcatalogueSubmissionAnswers(). -
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), elsesubmission.email. -
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: valuelines; a typed cost keeps only digits,.and-(so "Β£12,500" works), and anything still not numeric is left out rather than failing;methodologyandpriorityare kept only when valid; atarget_end_dateonly inYYYY-MM-DD. -
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_formis true when there was a submission, soproject_proposal_approval = formscatches it. -
Stamps the proposer (
form_submission_id,proposed_by_name,proposed_by_email,created_by_id = COALESCE(created_by_id, analyst)), then firesproject.proposal_submitteditself 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.
-
get.phpproposal-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_statusbefore 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#pvProposalon 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 fromon_approve; notes required to reject) and Edit (prjProposalEditModal) for those who may change the project, which saves the case and figures throughapi/projects/save.php.
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.
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.
| 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) |
| 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.
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_dateis today or in the last 30 days; - fire-once through
workflow_scheduled_emissionswith keyproject_benefit:<id>:reviewand 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_duewithbenefit{id, title, measure, unit, review_date, baseline_value, target_value, owner_analyst_id} andnotify_idsfromprojectBenefitNotifyIds().
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.
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.
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.
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.
-
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'splanned_date,spent_date,forecast_amount;position. Its three foreign keys (fk_pbl_projectCASCADE,fk_pbl_contractandfk_pbl_cost_centreSET NULL) are in$projectFksinapi/system/db_verify.php(#2243), along withproject_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_idhas no foreign key, because it points at a different table depending onscope. For that reasondeleteProject()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).
| 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.
/** 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 increateProject()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()(toolsbudget_currency {currency}) is a relabel - amounts are not converted, and the page says so before anyone confirms; allowed only withproject_currency_per_project = 1; auditedcurrency. - π Never add currencies.
projectLineActual()uses a linked contract's value only when its currency matches (or is blank); otherwise the line iscurrency_mismatchand contributes nothing. Anything totalling across projects must group bycurrency- the portfolio's charts draw one budget chart per currency.
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.
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.
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().
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").
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.
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 -undatedcounts 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;
actualis null after today. -
forecastis{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.
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).
| 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 |
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.
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 |
- No per-stage tolerances (the
stage_idcolumn 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 β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96