writing/tutorial/2026/11
● TutorialNov 13, 2026·20 min read

Project Management Saudization at 70%: a TypeScript Check

Ministerial Decision 41454 raises Saudization of project management roles to 70% from 14 February 2027, with a new list of three codes and no wage floor in the guide. Build the check in TypeScript: entity scope, rounding, replacement versus hiring, and a diff against the 2023 rules.

On 13 August 2026 the Ministry of Human Resources and Social Development issued Ministerial Decision 41454 with an updated procedural guide for project management professions. From 14 February 2027, every private establishment with three or more workers in those professions must have 70% of them Saudi, counted across the whole entity.

Most HR systems already hold a rule for these jobs: the 2023 guide under Decision 141749, which has required 40% since December 2024. Updating the rate from 40 to 70 is the easy part. The new guide also changes which occupation codes count, and it no longer states a wage floor. A check that only edits the percentage will count the wrong people.

This tutorial builds that check in TypeScript, from the two official guides, with tests you can run. It is written for the developer or HR systems lead who has to make the numbers correct before February, not for the person deciding whether to comply. If you are at that earlier stage, our news summary of the decision and the calendar of 2027 Saudization deadlines are the better starting points.

Prerequisites

  • Node.js 20 or later and TypeScript 5
  • An export of your workforce with, for each worker: nationality, the occupation code registered in Qiwa, the branch, the entity it belongs to, and the GOSI contributory wage
  • One extra column most exports do not have yet: whether the person actually does project management work. You get it from line managers, not from the system
  • Basic familiarity with Qiwa and Nitaqat terms

What You'll Build

A small module that answers five questions for each entity:

  1. How many workers are in scope under the 2026 guide, and does the decision apply at all?
  2. How many Saudis are required, with the guide's rounding?
  3. What is the gap if you replace non-Saudis, and what is it if you only hire?
  4. Who joins or leaves the scope compared to the 2023 rules your system holds today?
  5. Which workers create risk because their code and their actual work disagree?

The output is an action plan HR can read, with the number of days left in the grace period.

Step 1: What the Guide Actually Says

Everything below comes from two documents published on the ministry's site: the decision letter (No. 41454) and the eight-page procedural guide attached to it. The decision states that the guide is part of the decision.

The rate. 70% of all workers in the targeted professions, at entity level (guide, section 3.1).

The scope. Every private establishment in the Saudi market with three or more workers in the professions of table 1 (section 3.2). An establishment with two is outside the decision.

The entity. The guide defines it as the unit the ministry uses for Saudization: all branches with the same economic activity owned by one establishment. Your three project managers in Riyadh, Jeddah and Dammam are one group of three, not three groups of one.

The professions. Table 1 lists exactly three codes from the Saudi Unified Classification of Occupations:

CodeOccupation
121314Project management manager
214909Project management engineer
242108Project management specialist

The rounding. The worked example on page 5 takes five workers: 70% of 5 is 3.5, "rounded to the nearest whole number", so four Saudis are required. With two Saudis already, the establishment must replace two non-Saudis. A footnote adds that if Saudis are added instead of replacing, the calculation is run again on the new total.

Overlap. Clause three of the decision and section 3.2 of the guide say the same thing: if another decision targets the same profession at a different rate, the higher rate applies.

Dates. Issued 13 August 2026, a six-month grace period, enforcement from 14 February 2027 (section 3.5). The FAQ confirms that ministry services stay available during the grace period even if the rate is not met.

Penalties. Section 5 applies the violations table of Ministerial Decision 112377 (21/8/1447 AH). It also names a specific breach: assigning any task of these professions to a non-Saudi, directly or indirectly, under any other job title.

The FAQ adds three points that matter for code:

  • The decision applies whatever the establishment's Nitaqat band. A green band does not exempt you.
  • It applies to the occupation titles in table 1 and to the actual work of the worker.
  • Compliance is computed automatically from the occupation titles in the GOSI database and data from supervising bodies.

What changed from the 2023 guide

The 2023 guide under Decision 141749 (11/09/1444 AH) set 35% and then 40%, and it differs in two ways that matter more than the rate:

  • Seven codes, not three. It listed 121314 and 242108, plus five older seven-digit codes: 2133241 project manager, 2133231 project management office specialist, 1216031 telecommunications project manager, 1217011 business services project manager, and 1216011 transport project manager. The 2026 table drops those five and adds 214909, project management engineer.
  • A wage floor. Section 1.2 of the 2023 guide said a Saudi counts only if the GOSI contributory wage is at least SAR 6,000. The 2026 guide has no such clause anywhere in its eight pages.

The decision does not say in words that the floor is cancelled. Clause six repeals what conflicts with it, and clause three keeps earlier decisions working where they agree. Our reading is that the floor is gone, because the guide that defines the counting rules no longer contains it. Ask the ministry or your Qiwa account manager to confirm before you count a Saudi paid under SAR 6,000. The code below keeps the floor as a setting so you can switch it back on in one line.

Step 2: The Rules as Data

Keep each guide as a record, never as constants spread through the code. When the ministry publishes the next update, you add a record and the tests tell you what changed.

// src/rules.ts
export type OccupationCode = string;
 
export interface QuotaRule {
  decision: string;
  guideIssued: string;   // ISO date
  effectiveFrom: string; // ISO date
  ratePercent: number;   // whole percent, e.g. 70
  minWorkers: number;    // the rule applies from this many workers in scope
  codes: Record<OccupationCode, string>;
  gosiWageFloor: number | null; // SAR per month, null = no floor in the guide
}
 
// Procedural guide issued with Ministerial Decision 41454 (13 Aug 2026)
export const PM_2026: QuotaRule = {
  decision: "41454",
  guideIssued: "2026-08-13",
  effectiveFrom: "2027-02-14",
  ratePercent: 70,
  minWorkers: 3,
  codes: {
    "121314": "Project management manager",
    "214909": "Project management engineer",
    "242108": "Project management specialist",
  },
  gosiWageFloor: null,
};
 
// The 2023 guide for Ministerial Decision 141749, second phase (40%),
// in force since 11/06/1446 AH (about 12 Dec 2024)
export const PM_2023: QuotaRule = {
  decision: "141749",
  guideIssued: "2023-04-02",
  effectiveFrom: "2024-12-12",
  ratePercent: 40,
  minWorkers: 3,
  codes: {
    "121314": "Project management manager",
    "242108": "Project management specialist",
    "2133241": "Project manager",
    "2133231": "Project management office specialist",
    "1216031": "Telecommunications project manager",
    "1217011": "Business services project manager",
    "1216011": "Transport project manager",
  },
  gosiWageFloor: 6000,
};

The rate is a whole percent on purpose. Step 3 explains why.

Step 3: Rounding Without Floating Point

In JavaScript, 45 * 0.7 gives 31.499999999999996, not 31.5. Math.round then returns 31 where the guide's rule gives 32: one Saudi short, with a report that says you comply. The same happens at 85, 165 and 175 workers. Working in whole percents avoids the problem: multiply the headcount by 70, add 50, divide by 100 and drop the remainder. That is rounding to the nearest whole number, with halves going up, as in the guide's example.

// src/quota.ts
// Saudis required for a headcount, rounded to the nearest whole number
// (3.5 becomes 4, as in the guide's worked example).
// Integer arithmetic only: 45 * 0.7 is 31.499999999999996 in floating point.
export function requiredSaudis(headcount: number, ratePercent: number): number {
  return Math.floor((headcount * ratePercent + 50) / 100);
}
 
// Replacement: swap non-Saudis for Saudis, headcount stays the same.
export function gapByReplacement(
  headcount: number,
  saudis: number,
  ratePercent: number,
): number {
  return Math.max(0, requiredSaudis(headcount, ratePercent) - saudis);
}
 
// Hiring only: each new Saudi raises both sides of the ratio.
export function gapByHiring(
  headcount: number,
  saudis: number,
  ratePercent: number,
): number {
  if (ratePercent >= 100) {
    return saudis >= headcount ? 0 : Number.POSITIVE_INFINITY;
  }
  let hires = 0;
  while (saudis + hires < requiredSaudis(headcount + hires, ratePercent)) {
    hires += 1;
  }
  return hires;
}

The two gap functions answer different questions. Replacement keeps the headcount: swap a non-Saudi for a Saudi and only the numerator moves. Hiring adds people: each new Saudi raises the numerator and the denominator together, which is why the guide's footnote says to recalculate.

The difference is large at 70%. On the guide's own example, five workers with two Saudis, replacement needs two people and hiring alone needs four. With ten workers and three Saudis, replacement needs four and hiring alone needs twelve. The loop stops because at any rate under 100% the ratio of Saudis keeps rising with each hire. The guard for 100% exists so the same function can serve decisions that reserve a profession entirely.

Step 4: Scope Is the Code and the Work

The FAQ is explicit that the decision applies to the classified title and to the actual work. That gives two different failure cases, and the module records both:

  • A worker carries one of the three codes but does no project management. We leave that worker out of scope. If the work and the code disagree in this direction, the right fix is to correct the registration, and the report should make that visible rather than count the person.
  • A worker does project management under another code. For a non-Saudi, section 5 calls this a breach in its own right. For a Saudi, it is a lost count: the person helps your rate only once the right code is registered.
// src/assess.ts
import { QuotaRule } from "./rules";
import { requiredSaudis, gapByReplacement, gapByHiring } from "./quota";
 
export interface Worker {
  id: string;
  entityId: string;        // all branches with the same activity, one owner
  branchId: string;
  saudi: boolean;
  occupationCode: string;  // as registered in Qiwa / GOSI
  doesPmWork: boolean;     // what the person actually does, from the line manager
  gosiWage: number;        // contributory wage, SAR per month
}
 
export type Finding =
  | { kind: "title-mismatch"; workerId: string; saudi: boolean }
  | { kind: "below-wage-floor"; workerId: string; wage: number };
 
export interface EntityResult {
  entityId: string;
  inScope: number;
  saudisCounted: number;
  applies: boolean;
  required: number;
  rate: number;            // percent, one decimal
  byReplacement: number;
  byHiring: number;
  findings: Finding[];
}
 
export function assessEntity(
  entityId: string,
  workers: Worker[],
  rule: QuotaRule,
): EntityResult {
  const mine = workers.filter((w) => w.entityId === entityId);
  const findings: Finding[] = [];
 
  // Duties without the code. For a non-Saudi the guide calls this a breach;
  // a Saudi in the same position is simply not counted.
  for (const w of mine) {
    if (w.doesPmWork && !(w.occupationCode in rule.codes)) {
      findings.push({ kind: "title-mismatch", workerId: w.id, saudi: w.saudi });
    }
  }
 
  // Scope is the registered code AND the actual work.
  const scoped = mine.filter((w) => w.occupationCode in rule.codes && w.doesPmWork);
  const counted = scoped.filter((w) => {
    if (!w.saudi) return false;
    if (rule.gosiWageFloor !== null && w.gosiWage < rule.gosiWageFloor) {
      findings.push({ kind: "below-wage-floor", workerId: w.id, wage: w.gosiWage });
      return false;
    }
    return true;
  });
 
  const n = scoped.length;
  const s = counted.length;
  const applies = n >= rule.minWorkers;
  return {
    entityId,
    inScope: n,
    saudisCounted: s,
    applies,
    required: applies ? requiredSaudis(n, rule.ratePercent) : 0,
    rate: n === 0 ? 0 : Math.round((s / n) * 1000) / 10,
    byReplacement: applies ? gapByReplacement(n, s, rule.ratePercent) : 0,
    byHiring: applies ? gapByHiring(n, s, rule.ratePercent) : 0,
    findings,
  };
}

Three details in this function come straight from the guide:

  • Workers are filtered by entityId, not by branch, so the threshold of three and the 70% are both computed across branches.
  • The threshold test is n >= rule.minWorkers. Two workers is out, three is in.
  • The wage floor only runs when the rule has one. Under PM_2026 it is null; under PM_2023 a Saudi below SAR 6,000 is excluded and reported.

The rate is reported to one decimal, but compliance never uses it. Compliance compares counts, because the guide works in whole people.

Step 5: When Two Decisions Cover One Code

Engineers have their own Saudization decision, and 214909 sits in the engineering group of the classification. Whether a particular engineering decision lists 214909 is something to check in its own guide. The resolver below does not guess; it applies the rule both guides state: among the decisions in force on a date, the highest rate wins.

// src/overlap.ts
import { QuotaRule } from "./rules";
 
// When two decisions cover the same code, the higher rate applies.
// Only decisions already in force on the date are considered.
export function ruleFor(
  code: string,
  onDate: string,
  rules: QuotaRule[],
): QuotaRule | undefined {
  return rules
    .filter((r) => code in r.codes && r.effectiveFrom <= onDate)
    .sort((a, b) => b.ratePercent - a.ratePercent)[0];
}

This also handles the transition. Until 13 February 2027, the 2023 rule at 40% is the one in force. From 14 February, the 2026 rule at 70% takes over for the codes it covers. Feed it every decision you track and it returns the one that governs each worker on the date you ask about.

Step 6: Diff the 2023 and 2026 Lists

This is the step most teams will skip, and the one that catches the expensive mistakes. Run the old and the new rule over the same workforce and list who moves.

// src/diff.ts
import { QuotaRule } from "./rules";
import { Worker } from "./assess";
 
export interface RuleDiff {
  codesAdded: string[];
  codesRemoved: string[];
  workersJoining: string[];  // in scope under the new rule only
  workersLeaving: string[];  // in scope under the old rule only
  wageFloorChange: [number | null, number | null];
}
 
export function diffRules(
  oldRule: QuotaRule,
  newRule: QuotaRule,
  workers: Worker[],
): RuleDiff {
  const oldCodes = Object.keys(oldRule.codes);
  const newCodes = Object.keys(newRule.codes);
  const inOld = (w: Worker) => w.occupationCode in oldRule.codes;
  const inNew = (w: Worker) => w.occupationCode in newRule.codes;
  return {
    codesAdded: newCodes.filter((c) => !oldCodes.includes(c)),
    codesRemoved: oldCodes.filter((c) => !newCodes.includes(c)),
    workersJoining: workers.filter((w) => inNew(w) && !inOld(w)).map((w) => w.id),
    workersLeaving: workers.filter((w) => inOld(w) && !inNew(w)).map((w) => w.id),
    wageFloorChange: [oldRule.gosiWageFloor, newRule.gosiWageFloor],
  };
}

What to expect when you run it on a real export:

  • Workers joining: everyone registered as 214909, project management engineer. They were outside the 40% rule. They are now in the denominator, and if they are not Saudi they raise the number you need.
  • Workers leaving: everyone still on 2133241, 2133231, 1216031, 1217011 or 1216011. These seven-digit codes belong to the older classification. Before you celebrate a smaller denominator, check what Qiwa actually holds for those people today. If the codes were migrated to a current six-digit code, they may already be in one of the three.
  • Wage floor: 6,000 before, none in the 2026 guide. Saudis you excluded for a low wage may now count, subject to the confirmation in Step 1.

Step 7: From Findings to an Action Plan

HR does not read objects. Turn the result into lines they can act on, with the grace period counted in days.

// src/plan.ts
import { EntityResult } from "./assess";
 
export function phaseOn(today: string, effectiveFrom: string): "grace" | "enforced" {
  return today < effectiveFrom ? "grace" : "enforced";
}
 
export function daysLeft(today: string, effectiveFrom: string): number {
  const ms = Date.parse(effectiveFrom) - Date.parse(today);
  return Math.max(0, Math.ceil(ms / 86_400_000));
}
 
export function actionPlan(r: EntityResult, today: string, effectiveFrom: string): string[] {
  const lines: string[] = [];
  if (!r.applies) {
    lines.push(`Entity ${r.entityId}: ${r.inScope} in scope, below the threshold. Re-run on every hire.`);
    return lines;
  }
  lines.push(
    `Entity ${r.entityId}: ${r.saudisCounted}/${r.inScope} Saudi (${r.rate}%), ` +
      `${r.required} required.`,
  );
  if (r.byReplacement > 0) {
    lines.push(`Replace ${r.byReplacement}, or hire ${r.byHiring} additional Saudis.`);
    const phase = phaseOn(today, effectiveFrom);
    lines.push(
      phase === "grace"
        ? `${daysLeft(today, effectiveFrom)} days of grace left.`
        : "Grace period over: the entity is in breach now.",
    );
  }
  for (const f of r.findings) {
    if (f.kind === "title-mismatch" && f.saudi) {
      lines.push(`Worker ${f.workerId} is Saudi and not counted: register the right code in Qiwa.`);
    } else if (f.kind === "title-mismatch") {
      lines.push(`Worker ${f.workerId} does project management under another code: fix the role or the code.`);
    } else {
      lines.push(`Worker ${f.workerId} is below the wage floor (SAR ${f.wage}).`);
    }
  }
  return lines;
}

Testing Your Implementation

These tests pin every number the guide states, plus the edge cases around them. Run them with node --import tsx --test src/assess.test.ts.

// src/assess.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { PM_2023, PM_2026 } from "./rules";
import { requiredSaudis, gapByReplacement, gapByHiring } from "./quota";
import { assessEntity, Worker } from "./assess";
import { ruleFor } from "./overlap";
import { diffRules } from "./diff";
import { actionPlan, daysLeft } from "./plan";
 
const w = (id: string, saudi: boolean, code = "121314", extra: Partial<Worker> = {}): Worker => ({
  id, entityId: "E1", branchId: "B1", saudi, occupationCode: code,
  doesPmWork: true, gosiWage: 9000, ...extra,
});
 
test("guide example: 5 workers at 70% need 4 Saudis", () => {
  assert.equal(requiredSaudis(5, 70), 4);
  assert.equal(gapByReplacement(5, 2, 70), 2);
  assert.equal(gapByReplacement(5, 5, 70), 0);
});
 
test("rounding is to the nearest whole number", () => {
  assert.equal(requiredSaudis(3, 70), 2);  // 2.1
  assert.equal(requiredSaudis(4, 70), 3);  // 2.8
  assert.equal(requiredSaudis(15, 70), 11); // 10.5
  assert.equal(requiredSaudis(20, 70), 14);
  assert.equal(requiredSaudis(45, 70), 32); // 45 * 0.7 is 31.499999999999996
});
 
test("hiring only needs more people than replacement", () => {
  assert.equal(gapByHiring(5, 2, 70), 4);
  assert.equal(gapByHiring(10, 3, 70), 12);
  assert.equal(gapByHiring(10, 7, 70), 0);
  assert.equal(gapByHiring(3, 1, 100), Number.POSITIVE_INFINITY);
});
 
test("below three workers in scope the decision does not apply", () => {
  const r = assessEntity("E1", [w("a", false), w("b", false)], PM_2026);
  assert.equal(r.applies, false);
  assert.equal(r.byReplacement, 0);
});
 
test("branches of one entity are counted together", () => {
  const ws = [w("a", true), w("b", false, "214909", { branchId: "B2" }), w("c", false, "242108", { branchId: "B3" })];
  const r = assessEntity("E1", ws, PM_2026);
  assert.equal(r.inScope, 3);
  assert.equal(r.required, 2);
  assert.equal(r.byReplacement, 1);
});
 
test("a code without the work is out of scope; work without the code is flagged", () => {
  const ws = [
    w("a", true), w("b", true), w("c", false),
    w("d", false, "121314", { doesPmWork: false }),
    w("e", false, "263101"),
    w("f", true, "263101"),
  ];
  const r = assessEntity("E1", ws, PM_2026);
  assert.equal(r.inScope, 3);
  assert.equal(r.saudisCounted, 2);
  assert.deepEqual(r.findings, [
    { kind: "title-mismatch", workerId: "e", saudi: false },
    { kind: "title-mismatch", workerId: "f", saudi: true },
  ]);
});
 
test("the 2026 guide sets no wage floor; the 2023 one did", () => {
  const ws = [w("a", true, "121314", { gosiWage: 5000 }), w("b", true), w("c", false)];
  assert.equal(assessEntity("E1", ws, PM_2026).saudisCounted, 2);
  const old = assessEntity("E1", ws, PM_2023);
  assert.equal(old.saudisCounted, 1);
  assert.equal(old.findings[0].kind, "below-wage-floor");
});
 
test("diff between the 2023 and 2026 lists", () => {
  const ws = [w("a", true, "214909"), w("b", false, "2133241"), w("c", false, "121314")];
  const d = diffRules(PM_2023, PM_2026, ws);
  assert.deepEqual(d.codesAdded, ["214909"]);
  assert.equal(d.codesRemoved.length, 5);
  assert.deepEqual(d.workersJoining, ["a"]);
  assert.deepEqual(d.workersLeaving, ["b"]);
  assert.deepEqual(d.wageFloorChange, [6000, null]);
});
 
test("higher rate wins only once in force", () => {
  assert.equal(ruleFor("121314", "2026-12-01", [PM_2023, PM_2026])?.decision, "141749");
  assert.equal(ruleFor("121314", "2027-02-14", [PM_2023, PM_2026])?.decision, "41454");
  assert.equal(ruleFor("999999", "2027-02-14", [PM_2026]), undefined);
});
 
test("action plan counts grace days", () => {
  assert.equal(daysLeft("2026-11-16", "2027-02-14"), 90);
  const r = assessEntity("E1", [w("a", true), w("b", false), w("c", false)], PM_2026);
  const plan = actionPlan(r, "2026-11-16", PM_2026.effectiveFrom);
  assert.match(plan[1], /Replace 1, or hire 3/);
  assert.match(plan[2], /90 days/);
  const late = actionPlan(r, "2027-02-14", PM_2026.effectiveFrom);
  assert.match(late[2], /in breach now/);
});

Ten tests, all passing on Node 20 and later with tsc --strict clean. We also changed the code on purpose (rounding by 49 instead of 50, Math.round on a floating-point rate, a strict threshold, the wrong sort order, dropping the actual-work filter, ignoring the effective date, removing the 100% guard) and checked that at least one test fails for each change.

One check the tests cannot do: compare the module's result with what Qiwa shows for the same entity. The ministry computes from GOSI data. If the two disagree, the cause is almost always the data: a code registered differently in GOSI than in your HR system, or a branch attached to a different entity.

Troubleshooting

Qiwa shows a different headcount from your export. Compare the entity grouping first. Branches with a different economic activity may be a separate entity, and a branch you treat as separate may be grouped with the head office.

A Saudi is not counted, and the wage is above 6,000. Check the registered code. A Saudi project manager registered under a generic management code does not count, whatever their real job. The title-mismatch finding with saudi: true is that case.

The numbers jump on 14 February. That is the resolver switching from the 40% rule to the 70% rule. Run the assessment for both dates during the grace period so the jump is planned.

You track several decisions and the counts look doubled. Each worker should be assessed under one rule on a given date. Use ruleFor to assign the rule per worker, then group by rule before calling assessEntity.

Five workers, three Saudis, and the module says you are short. 70% of 5 is 3.5, rounded to 4. Three is not enough. This is the case the guide uses as its example.

Next Steps

Conclusion

The 70% figure is the headline, but it is not where systems will go wrong. They will go wrong on the code list (three codes now, including 214909, not the seven from 2023), on the wage floor the 2026 guide no longer states, on counting by branch instead of by entity, and on workers whose title and real work do not match. Each of these is a few lines of code and one test, and the diff in Step 6 shows you, on your own data, which ones touch you.

You have three months of grace left. That is enough to correct registrations in Qiwa, which costs nothing, before deciding how many people to hire, which costs a great deal.

If you want this check wired to your HR system and run before every hire, book a free diagnostic session and we will review your data against the 2026 guide with you.

Send us your project

We reply within one working day.