writing/tutorial/2026/11
● TutorialNov 9, 2026·22 min read

Engineering Technician Saudization at 35%: a TypeScript Engine

Build the compliance engine for the Saudi engineering technician quota before it rises from 30% to 35% on 9 May 2027: dated rate steps, the higher-rate-wins rule across decisions, Saudi Council of Engineers accreditation and the SAR 5,000 GOSI floor, the actual-work rule, and the register-by date that the five-day sync lag forces on you.

On 8 November 2026, the Ministry of Human Resources and Social Development (HRSD) and the Ministry of Municipalities and Housing announced that the Saudization rate for 94 technical engineering professions rises from 30% to 35% on 9 May 2027. It applies to every private establishment with five or more workers in those professions, counted at the level of the establishment entity. Our news report on the decision covers what changed. This tutorial is for the person who has to produce the number: an HR systems developer at a contractor, a maintenance company or an engineering office.

The arithmetic looks trivial. It is not, for four reasons that each cost a head or two: a Saudi technician only counts if two outside systems agree, the decision follows the work and not just the job title, the hire you plan for May has to be registered in April, and "hire 11" in the ministry's own example does not mean what a recruiter will think it means.

Prerequisites

  • Node.js 20 or later, and TypeScript 5 in strict mode
  • An export of your workforce with occupation codes as registered in Qiwa, nationality, and the contributory wage registered with GOSI
  • Accreditation status from the Saudi Council of Engineers for each Saudi technician
  • The current HRSD procedural guide for the decision. When we checked on 9 November 2026, the 2026 guide was announced but not yet downloadable, so the rules below come from the 2025 guide that set the 30% rate. Re-read the new guide before you rely on any of them.

What You'll Build

A small engine, about 190 lines, that answers five questions for any date:

  1. Which rate is in force: 30% until 8 May 2027, 35% from 9 May 2027
  2. Which decision governs each occupation code, when more than one decision covers it
  3. Which of your Saudi technicians actually count, and every reason the others do not
  4. How many Saudis you need, by replacement and by net new hiring, which are different numbers
  5. The last date a change must be registered to show up in the ministry's count on a given day

It reuses the design of our accounting Saudization quota engine and the gym outlet engine, so where a step is explained in full there, we link to it rather than repeat it.

Step 1: What the Guide Actually Says

The 2025 procedural guide for technical engineering professions is eight pages. The parts that turn into code:

  • The rate is a percentage of all workers in the targeted professions in the establishment. It was 30% from 23 July 2025, per the guide's timeline (the ministry's notice said 27 July). It becomes 35% on 9 May 2027.
  • The scope is every private establishment with five or more workers in the targeted professions.
  • Professional accreditation from the Saudi Council of Engineers is a condition. Technicians who are not accredited are not counted toward the rate.
  • The wage floor is a registered monthly GOSI wage of at least SAR 5,000. A Saudi technician paid less is not counted.
  • The higher rate wins. Where a technical engineering profession is also targeted by another localization decision at a different rate, the higher rate applies.
  • Title and actual work. The FAQ says the decision applies to the registered occupational title and to the work actually done. Giving targeted tasks to a non-Saudi, directly or indirectly, under another job title is a breach.
  • Rounding is to the nearest whole number, in the guide's worked example.
  • Monitoring is automatic. The FAQ says the system matches GOSI data against occupational titles, wages and accreditation, and that GOSI and Saudi Council of Engineers updates show up within five working days at most.

One more FAQ answer matters: an establishment whose technicians are all Saudi still has to meet the conditions. The guide's example is 20 technicians, all Saudi, of whom at least 6 must meet the wage and accreditation conditions.

Step 2: Decisions as Data

Treat each occupational decision as a record with dated steps, rather than a constant. You will add the next decision, and the next step of this one, without touching the logic.

/** A dated rate step: from this date (inclusive), this percentage applies. */
export interface RateStep {
  readonly from: string; // ISO date, YYYY-MM-DD
  readonly ratePercent: number; // integer, e.g. 35
}
 
export interface OccupationDecision {
  readonly id: string;
  /** Unified Saudi Classification of Occupations codes covered by the decision. */
  readonly codes: ReadonlySet<string>;
  /** Steps in date order. */
  readonly steps: readonly RateStep[];
  /** The decision applies only at or above this many workers in the covered codes. */
  readonly minWorkers: number;
  /** Minimum GOSI contributory wage, in SAR, for a Saudi to be counted. */
  readonly minWageSar: number;
}
 
export const ENGINEERING_TECHNICIANS: OccupationDecision = {
  id: 'engineering-technicians',
  // Seven of the codes printed in the 2025 procedural guide. Load the full list
  // from the current guide; do not ship this sample as your production table.
  codes: new Set([
    '311201', // civil engineering technician
    '311211', // surveying technician
    '311301', // electrical engineering technician
    '311409', // electronics engineering technician
    '311501', // mechanical engineering technician
    '311511', // mechanical maintenance technician
    '311805', // architectural draughtsman
  ]),
  steps: [
    { from: '2025-07-23', ratePercent: 30 },
    { from: '2027-05-09', ratePercent: 35 },
  ],
  minWorkers: 5,
  minWageSar: 5000,
};
 
/** The rate in force on a date, or null before the first step. */
export function rateOn(decision: OccupationDecision, isoDate: string): number | null {
  let rate: number | null = null;
  for (const step of decision.steps) {
    if (step.from <= isoDate) rate = step.ratePercent;
  }
  return rate;
}

The seven codes are the seven professions named in the ministry's announcement, as printed in the 2025 guide's table. The real table has 94 entries. Load it from the guide rather than from this article, and keep it under version control with the guide's date next to it, because the next revision will change it.

Dates are ISO strings so that plain string comparison orders them correctly. No time zones, no Date parsing, no off-by-one at midnight in Riyadh.

Step 3: When Two Decisions Cover One Code

A contractor rarely lives under one decision. Engineers have their own decision, project management has another, and a code can appear in more than one table. The guide resolves overlaps with a single rule: the higher rate applies. In code, that means every worker belongs to exactly one decision on a given date, the one with the highest rate that covers the code.

/**
 * Where one code is covered by several decisions, the guide applies the higher rate.
 * Returns the decision that governs this code on this date, or null if none does.
 */
export function governingDecision(
  code: string,
  isoDate: string,
  decisions: readonly OccupationDecision[],
): OccupationDecision | null {
  let best: OccupationDecision | null = null;
  let bestRate = -1;
  for (const d of decisions) {
    if (!d.codes.has(code)) continue;
    const rate = rateOn(d, isoDate);
    if (rate !== null && rate > bestRate) {
      best = d;
      bestRate = rate;
    }
  }
  return best;
}

This has a consequence worth stating: a worker can move from one pool to another on the day a rate steps up, without any change in your company. When you compute several decisions, compute them all from the same date and the same staff list, or you will count some people twice and others not at all.

Step 4: Which Saudis Count

A Saudi technician counts only if three things are true at once: nationality, a GOSI contributory wage of at least SAR 5,000, and an active Saudi Council of Engineers accreditation. The first comes from your HR system, the second from GOSI, the third from the Council. Your HR dashboard usually knows only the first.

export interface Worker {
  readonly id: string;
  readonly occupationCode: string;
  readonly saudi: boolean;
  /** Contributory wage registered with GOSI, in SAR. Not gross pay. */
  readonly gosiWageSar: number;
  /** Active professional accreditation with the Saudi Council of Engineers. */
  readonly sceAccredited: boolean;
  /**
   * True when a line manager confirms the person does targeted technical work
   * under a title outside the list. The decision follows the actual work.
   */
  readonly doesTargetedWork?: boolean;
}
 
export type Exclusion = 'NOT_SAUDI' | 'WAGE_BELOW_FLOOR' | 'NOT_SCE_ACCREDITED';
 
/** Every reason this worker does not count toward the numerator. Empty means countable. */
export function exclusions(w: Worker, decision: OccupationDecision): Exclusion[] {
  const reasons: Exclusion[] = [];
  if (!w.saudi) reasons.push('NOT_SAUDI');
  if (w.gosiWageSar < decision.minWageSar) reasons.push('WAGE_BELOW_FLOOR');
  if (!w.sceAccredited) reasons.push('NOT_SCE_ACCREDITED');
  return reasons;
}

Return every reason, not the first one. A technician who is both under the floor and unaccredited needs two fixes, and the accreditation fix has the longer lead time. If you report only the wage problem, someone raises the wage and discovers the second problem in May.

Wage means the contributory wage registered with GOSI, not the gross pay on your payroll report. The two drift apart more often than people expect. The GOSI contribution engine shows where.

The doesTargetedWork flag is how the engine handles the actual-work rule. No export will tell you that a non-Saudi registered as a site supervisor spends their days doing a surveying technician's job. Only a line manager can. Collect the answer once a quarter, store it, and let the engine pull those people into scope and list them separately.

Step 5: Rounding Without a Float

The guide's example multiplies 51 technicians by 30% to get 15.3 and rounds to 15. In floating point, 51 * 0.35 is 17.849999999999998, which happens to round correctly, but other inputs will not be so kind. Keep the rate as an integer percentage and round in integers.

/**
 * Required Saudis = workers x rate, rounded to the nearest whole number.
 * Integer arithmetic only; a half rounds up (the guide does not say, this is our reading).
 */
export function requiredSaudis(workers: number, ratePercent: number): number {
  return Math.floor((workers * ratePercent + 50) / 100);
}

The guide does not say what happens at exactly one half. We round a half up, which is the cautious reading, and the code says so. If the new guide says otherwise, this is the only line that changes. The accounting engine tutorial walks through the float failure in more detail.

Step 6: Assessing the Establishment

Now combine the pieces. The assessment returns both gap figures, the excluded Saudis with their reasons, and the people pulled in by the actual-work rule.

export interface Assessment {
  readonly decisionId: string;
  readonly isoDate: string;
  readonly applies: boolean;
  readonly ratePercent: number;
  readonly workers: number;
  readonly countableSaudis: number;
  readonly required: number;
  /** Non-Saudis to replace with countable Saudis. Denominator unchanged. */
  readonly byReplacement: number;
  /** Net new countable Saudi hires if nobody leaves. Denominator grows. */
  readonly byHiringOnly: number;
  /** Saudis in scope who do not count, with every reason. */
  readonly excluded: ReadonlyArray<{ id: string; reasons: Exclusion[] }>;
  /** People counted in scope only because of the actual-work rule. */
  readonly actualWorkFlags: readonly string[];
}
 
export function hiresOnly(workers: number, countable: number, ratePercent: number): number {
  if (ratePercent >= 100) throw new Error('rate must be under 100%');
  let k = 0;
  while (countable + k < requiredSaudis(workers + k, ratePercent)) k++;
  return k;
}
 
export function assess(
  staff: readonly Worker[],
  decision: OccupationDecision,
  all: readonly OccupationDecision[],
  isoDate: string,
): Assessment {
  const rate = rateOn(decision, isoDate) ?? 0;
  const inScope: Worker[] = [];
  const actualWorkFlags: string[] = [];
  for (const w of staff) {
    if (governingDecision(w.occupationCode, isoDate, all) === decision) {
      inScope.push(w);
    } else if (w.doesTargetedWork && !w.saudi) {
      inScope.push(w);
      actualWorkFlags.push(w.id);
    }
  }
  const workers = inScope.length;
  const applies = rate > 0 && workers >= decision.minWorkers;
  const excluded: { id: string; reasons: Exclusion[] }[] = [];
  let countable = 0;
  for (const w of inScope) {
    const reasons = exclusions(w, decision);
    if (reasons.length === 0) countable++;
    else if (w.saudi) excluded.push({ id: w.id, reasons });
  }
  const required = applies ? requiredSaudis(workers, rate) : 0;
  return {
    decisionId: decision.id,
    isoDate,
    applies,
    ratePercent: rate,
    workers,
    countableSaudis: countable,
    required,
    byReplacement: Math.max(0, required - countable),
    byHiringOnly: applies ? hiresOnly(workers, countable, rate) : 0,
    excluded,
    actualWorkFlags,
  };
}

Two choices in this function deserve a note.

The denominator includes every worker in scope, Saudi or not, countable or not. That is what the guide's all-Saudi example implies: 20 technicians, all Saudi, need 6 who qualify. If unqualified Saudis were removed from the denominator, that answer would be different.

The threshold uses the same scope. Five workers in the targeted professions, after the overlap rule and the actual-work rule. A firm with four technicians and one engineer-titled employee doing technician work has five.

Step 7: Hire 11 Does Not Mean Hire 11

The guide's worked example: 51 technicians, 4 of them Saudi, at 30%. Required is 15, so the guide says the establishment must employ 11 additional Saudi technicians.

That is right if each of the 11 replaces a non-Saudi, so the total stays at 51. It is wrong if the 11 are added on top. With 62 technicians, the requirement becomes 18.6, rounded to 19, and 15 Saudis fall short. Hiring only, the establishment needs 16 net new countable Saudis to comply at 30%.

At 35%, the same establishment needs 18 Saudis out of 51: 14 by replacement, or 21 by hiring only.

RateRequired of 51By replacementBy hiring only
30%151116
35%181421

Show both numbers in any dashboard, with the labels in the type. A bare "gap: 14" read as a hiring target leaves a project manager seven heads short on 9 May. Most real plans are a mix: some replacements as contracts end, some net hires for new projects. The two numbers bound the answer.

Step 8: The Register-By Date

The ministry counts from GOSI and Council data, and the guide says updates show within five working days at most. So a Saudi technician hired on Thursday 6 May 2027, or accredited that day, may not be in the count on Sunday 9 May. Plan backwards.

/** Saudi working week: Sunday to Thursday. Friday (5) and Saturday (6) are off. */
function isWorkingDay(d: Date): boolean {
  const day = d.getUTCDay();
  return day !== 5 && day !== 6;
}
 
/**
 * Latest date a hire, wage change or accreditation must be registered so that it
 * shows in the ministry's count by `isoDate`, given a sync lag in working days.
 * The guide says updates show within five working days at most.
 */
export function registerBy(isoDate: string, lagWorkingDays = 5): string {
  const d = new Date(`${isoDate}T00:00:00Z`);
  let left = lagWorkingDays;
  while (left > 0) {
    d.setUTCDate(d.getUTCDate() - 1);
    if (isWorkingDay(d)) left--;
  }
  return d.toISOString().slice(0, 10);
}

With the Saudi working week of Sunday to Thursday, five working days before Sunday 9 May 2027 is Sunday 2 May 2027. That is the last safe day to have a new hire registered with GOSI, a wage raised above SAR 5,000, and an accreditation active. Accreditation itself takes longer than five days, so for new graduates the real deadline is weeks earlier. Ask the Council for its current processing time and subtract it as well.

Testing Your Implementation

Run the tests with npx tsx --test engine.test.ts. They pin the guide's own numbers, the FAQ's all-Saudi case, the threshold, the overlap rule and the date arithmetic, and check two invariants over every establishment from 5 to 300 technicians.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  ENGINEERING_TECHNICIANS as ET, assess, hiresOnly, rateOn,
  registerBy, requiredSaudis, type OccupationDecision, type Worker,
} from './engine.ts';
 
function crew(n: number, saudi: number, code = '311201'): Worker[] {
  return Array.from({ length: n }, (_, i) => ({
    id: `w${i}`, occupationCode: code, saudi: i < saudi,
    gosiWageSar: 6000, sceAccredited: true,
  }));
}
 
test('rate steps from 30% to 35% on 9 May 2027', () => {
  assert.equal(rateOn(ET, '2025-07-22'), null);
  assert.equal(rateOn(ET, '2027-05-08'), 30);
  assert.equal(rateOn(ET, '2027-05-09'), 35);
});
 
test("the guide's worked example: 51 technicians, 4 Saudi, at 30%", () => {
  const a = assess(crew(51, 4), ET, [ET], '2026-11-09');
  assert.equal(a.required, 15);
  assert.equal(a.byReplacement, 11);
  assert.equal(a.byHiringOnly, 16);
});
 
test('the same establishment at 35%', () => {
  const a = assess(crew(51, 4), ET, [ET], '2027-05-09');
  assert.equal(a.required, 18);
  assert.equal(a.byReplacement, 14);
  assert.equal(a.byHiringOnly, 21);
});
 
test('all-Saudi FAQ: 20 Saudis still need 6 who meet both conditions', () => {
  const staff = crew(20, 20).map((w, i) =>
    i < 6 ? w : { ...w, sceAccredited: false });
  const a = assess(staff, ET, [ET], '2026-11-09');
  assert.equal(a.required, 6);
  assert.equal(a.byReplacement, 0);
  assert.equal(a.excluded.length, 14);
});
 
test('four workers in scope: the decision does not apply', () => {
  const a = assess(crew(4, 0), ET, [ET], '2027-05-09');
  assert.equal(a.applies, false);
  assert.equal(a.byHiringOnly, 0);
});
 
test('every failing reason is kept, not just the first', () => {
  const [w] = crew(1, 1);
  const a = assess(
    [{ ...w!, gosiWageSar: 4800, sceAccredited: false }, ...crew(5, 0).map((x) => ({ ...x, id: `n${x.id}` }))],
    ET, [ET], '2026-11-09');
  assert.deepEqual(a.excluded[0]?.reasons, ['WAGE_BELOW_FLOOR', 'NOT_SCE_ACCREDITED']);
});
 
test('wage floor is inclusive at SAR 5,000', () => {
  const staff = crew(10, 3).map((w, i) => (i === 0 ? { ...w, gosiWageSar: 5000 } : w));
  assert.equal(assess(staff, ET, [ET], '2026-11-09').countableSaudis, 3);
});
 
test('actual-work rule pulls an off-list non-Saudi into scope', () => {
  const staff: Worker[] = [
    ...crew(5, 2),
    { id: 'x', occupationCode: '999999', saudi: false, gosiWageSar: 3000,
      sceAccredited: false, doesTargetedWork: true },
  ];
  const a = assess(staff, ET, [ET], '2026-11-09');
  assert.equal(a.workers, 6);
  assert.deepEqual(a.actualWorkFlags, ['x']);
});
 
test('higher rate wins when two decisions cover one code', () => {
  const other: OccupationDecision = {
    id: 'other', codes: new Set(['311201']),
    steps: [{ from: '2025-01-01', ratePercent: 50 }], minWorkers: 1, minWageSar: 4000,
  };
  const all = [ET, other];
  assert.equal(assess(crew(10, 0), ET, all, '2026-11-09').workers, 0);
  assert.equal(assess(crew(10, 0), other, all, '2026-11-09').workers, 10);
});
 
test('rounding: half rounds up, .3 rounds down, no float drift', () => {
  assert.equal(requiredSaudis(10, 35), 4); // 3.5
  assert.equal(requiredSaudis(51, 30), 15); // 15.3
  assert.equal(requiredSaudis(51, 35), 18); // 17.85
  assert.equal(requiredSaudis(3, 30), 1); // 0.9
});
 
test('hiring-only never beats replacement, and replacement always complies', () => {
  for (let n = 5; n <= 300; n++) {
    for (let s = 0; s <= n; s++) {
      for (const r of [30, 35]) {
        const req = requiredSaudis(n, r);
        const rep = Math.max(0, req - s);
        assert.ok(hiresOnly(n, s, r) >= rep);
        assert.ok(s + rep >= requiredSaudis(n, r));
      }
    }
  }
});
 
test('register by: five Saudi working days before Sunday 9 May 2027', () => {
  assert.equal(registerBy('2027-05-09'), '2027-05-02');
  assert.equal(registerBy('2027-05-09', 0), '2027-05-09');
});

The last property test is the one that earns its place. Over about 90,000 combinations of size, Saudi count and rate, it checks that hiring only never needs fewer people than replacement, and that the replacement figure always makes the establishment compliant. An off-by-one in the rounding fails it immediately.

Troubleshooting

The engine shows fewer countable Saudis than HR expects. Print excluded. The usual causes are an accreditation that lapsed at renewal and a GOSI wage registered a little below SAR 5,000 while payroll pays more.

The workforce count does not match the Qiwa dashboard. Check the overlap rule first. A code covered by a higher-rate decision leaves this pool. Then check actualWorkFlags: those people are in scope because a manager said so, and Qiwa does not know.

The 2026 guide changes a rule. Every rule lives in one place: the decision record, requiredSaudis, or exclusions. Change the record or the function, and the tests tell you which of the guide's examples moved.

Below five workers, nothing applies. That is correct, and fragile. Two hires can bring a firm into scope. Run the assessment on your hiring plan, not only on today's staff.

Next Steps

Conclusion

The 35% rate is six months away, which is enough for one hiring cycle if you know the real gap now. The engine gives you that gap in two honest forms, names every Saudi who does not count and why, follows the work rather than the title, and turns 9 May into the date that actually matters: 2 May, minus the Council's lead time.

If you would rather have this check run against your Qiwa, GOSI and accreditation data automatically than rebuild it by hand, talk to us. We will look at where those three sources live in your company and where they stop agreeing.