From 18 November 2026, every private sports centre and gym in Saudi Arabia with four or more workers in twelve coaching occupations must have 15% of those workers be Saudi. The decision is Ministerial Decision 72002 of 27/05/1447 AH, issued by the Ministry of Human Resources and Social Development with the Ministry of Sport, and its twelve-month grace period ends that day. We covered the announcement in Saudi gyms must Saudize 15% of 12 coaching roles.
The percentage is the easy part. The ministry's procedural guide settles three things that decide whether your dashboard tells the truth: the count is made per licensed outlet, not per company; the four-worker threshold counts only the targeted occupations; and the result is rounded to the nearest whole person, with a printed table you can test against. A chain can be compliant on its total and in breach at several branches.
This tutorial builds the engine that answers the question per outlet, then across the chain.
Prerequisites
- Node.js 20 or later and TypeScript 5.x
- Read access to the occupation code registered on each worker's contract in Qiwa, per Ministry of Sport licence
- The class timetable or personal-training booking export, if you want the disguised-coach check in Step 5
- Familiarity with the rounding problem from the accounting Saudization engine helps, but is not required
What You'll Build
A pure, dependency-free module that answers five questions for any outlet and date:
- Is this outlet in scope, by registration or by what it actually runs?
- How many workers sit in the twelve targeted occupations, and how many Saudis must there be?
- Are the penalties live yet, or is this still the grace period?
- Who is coaching without a coaching occupation on their contract?
- Across a chain, which Saudi coaches could move between branches, and what does the rest cost in replacements or hires?
Every constant comes from the guide, downloaded from hrsd.gov.sa on 2 November 2026. Every output below was produced by running the code.
Step 1: What the Guide Actually Says
Four rules from the procedural guide shape the whole engine.
The unit is the outlet. The guide defines a sports outlet as any commercial entity licensed by the Ministry of Sport to run a sports centre or hall, and says every centre or hall holding its own licence is a separate outlet for the decision. Branches are not pooled.
The threshold counts targeted workers. Section 3.2 applies the decision to outlets where the total number of workers in the occupations of table 2 is four or more. An outlet with three trainers and twenty receptionists, cleaners and accountants is outside it. Its FAQ repeats the same reading: four or more workers in a sports occupation listed in the guide.
Scope follows the actual activity. The decision covers ISIC4 activities 931221 (men's sports halls and centres) and 931222 (women's). Section 3.2 extends it to any outlet that runs one of those activities under a commercial registration or licence for a different activity, and the FAQ confirms the actual activity counts regardless of what the registration says.
Rounding is to the nearest integer. The guide prints a table from 4 to 40 targeted workers and states the rule: 0.49 and below rounds to zero, 0.50 and above rounds to one. Its worked example is an outlet with 23 staff, of whom 6 sports coaches and 4 fitness trainers are targeted: 10 × 15% = 1.5, so two Saudis are required. The sales manager, accountants, receptionists and guards are not in the count.
What the guide does not say, and this engine does not invent: a wage floor or GOSI condition for a Saudi to count, a fine amount (it points to the violations tables of Ministerial Decisions 75913 and 44558), and whether moving a worker between two licences of the same company needs a Qiwa transaction. Those are flagged wherever they matter.
Step 2: The Reference Tables
Put every constant in one file, with its source in a comment. The twelve codes come from the Saudi Standard Classification of Occupations, as listed in the guide's table 2.
Create src/rules.ts:
// Ministerial Decision 72002 of 27/05/1447 AH and its procedural guide (HRSD).
/** Saudis required as a share of workers in the targeted occupations. */
export const RATE_PERCENT = 15;
/** The decision bites only at four or more workers in the targeted occupations. */
export const MIN_TARGETED_WORKERS = 4;
/** End of the 12-month grace period: 18/11/2026 = 8/6/1448 AH. */
export const ENFORCEMENT_FROM = '2026-11-18';
/** ISIC4 activity codes, guide table 1. */
export const TARGETED_ACTIVITIES: Readonly<Record<string, string>> = {
'931221': "Men's sports halls and centres",
'931222': "Women's sports halls and centres",
};
/** Saudi Standard Classification of Occupations codes, guide table 2. */
export const TARGETED_OCCUPATIONS: Readonly<Record<string, string>> = {
'342201': 'Sports coach',
'342203': 'Professional football coach',
'342205': 'Professional ball sports coach',
'342207': 'Professional self-defence coach',
'342209': 'Professional racket sports coach',
'342211': 'Professional aquatic sports coach',
'342213': 'Professional ice sports coach',
'342217': 'Professional equestrian coach',
'342219': 'Professional athletics coach',
'342221': 'Sports supervisor',
'342301': 'Fitness trainer',
'342302': 'Personal trainer',
};
export function isTargetedOccupation(code: string): boolean {
return Object.prototype.hasOwnProperty.call(TARGETED_OCCUPATIONS, code);
}The occupation code is the one registered on the contract, not the title used on the gym floor. A head coach registered as a sports supervisor is counted as 342221. A coach registered as a receptionist is not counted at all, and that is the problem Step 5 deals with.
Step 3: Rounding Without a Float
The guide's rule is round half up. Math.round(n * 0.15) looks right and is the wrong habit: 0.15 has no exact binary representation, and the accounting engine showed the same pattern losing a whole person at 70%. Keep the arithmetic in integers. Percentage points are whole numbers, so multiply first, add half the divisor, then divide.
Create src/required.ts:
import { MIN_TARGETED_WORKERS, RATE_PERCENT } from './rules';
/**
* Saudis required in one outlet. The guide rounds to the nearest whole
* number: 0.49 and below goes down, 0.50 and above goes up. Integer-only,
* so no float ever enters.
*/
export function requiredSaudis(targetedWorkers: number): number {
if (!Number.isInteger(targetedWorkers) || targetedWorkers < 0) {
throw new RangeError(`targetedWorkers must be a whole number, got ${targetedWorkers}`);
}
if (targetedWorkers < MIN_TARGETED_WORKERS) return 0;
return Math.floor((targetedWorkers * RATE_PERCENT + 50) / 100);
}The threshold lives here, not in the caller. Below four targeted workers nothing is required, and returning zero keeps the rest of the engine simple. At exactly four, 4 × 15% = 0.6 rounds to one, which matches the guide's FAQ: an outlet with four workers in a sports occupation must employ at least one Saudi.
Step 4: Assessing One Outlet
An assessment needs the outlet's registration, what it actually runs, and its roster. The status is one of four values, and the order of the checks matters: activity first, threshold second, rate last.
Create src/outlet.ts:
import { ENFORCEMENT_FROM, TARGETED_ACTIVITIES, isTargetedOccupation } from './rules';
import { requiredSaudis } from './required';
export interface Worker {
id: string;
/** The occupation code registered on the contract in Qiwa, not the floor title. */
occupationCode: string;
saudi: boolean;
}
export interface Outlet {
/** Ministry of Sport licence number. One licence = one outlet. */
licenceNo: string;
name: string;
/** ISIC4 activity on the commercial registration or licence. */
registeredActivity: string;
/** What the outlet actually runs, in ISIC4 codes. */
actualActivities: string[];
workers: Worker[];
}
export type OutletStatus = 'OUT_OF_SCOPE_ACTIVITY' | 'BELOW_THRESHOLD' | 'COMPLIANT' | 'SHORT';
export interface OutletAssessment {
licenceNo: string;
status: OutletStatus;
targeted: number;
saudis: number;
required: number;
gap: number;
/** In scope only because of what it actually does, not what it registered. */
scopeFromActualActivity: boolean;
/** False during the grace period: the rate applies, the penalties do not. */
enforceable: boolean;
}
const isTargetedActivity = (code: string): boolean =>
Object.prototype.hasOwnProperty.call(TARGETED_ACTIVITIES, code);
export function assessOutlet(outlet: Outlet, asOf: string): OutletAssessment {
const byRegistration = isTargetedActivity(outlet.registeredActivity);
const byActual = outlet.actualActivities.some(isTargetedActivity);
const targetedWorkers = outlet.workers.filter((w) => isTargetedOccupation(w.occupationCode));
const targeted = targetedWorkers.length;
const saudis = targetedWorkers.filter((w) => w.saudi).length;
const required = requiredSaudis(targeted);
const gap = Math.max(0, required - saudis);
let status: OutletStatus;
if (!byRegistration && !byActual) status = 'OUT_OF_SCOPE_ACTIVITY';
else if (required === 0) status = 'BELOW_THRESHOLD';
else status = gap === 0 ? 'COMPLIANT' : 'SHORT';
return {
licenceNo: outlet.licenceNo,
status,
targeted,
saudis,
required: status === 'OUT_OF_SCOPE_ACTIVITY' ? 0 : required,
gap: status === 'SHORT' ? gap : 0,
scopeFromActualActivity: !byRegistration && byActual,
enforceable: asOf >= ENFORCEMENT_FROM,
};
}Two fields earn their place. scopeFromActualActivity is true for the outlet registered as something else that runs a gym floor anyway, for example a gym floor run under a retail or hotel licence. Show it separately, because whoever reads the dashboard will assume the outlet is exempt. enforceable is false before 18 November 2026: during the grace period the guide expects the rate to be applied but holds back the penalties. After it, the FAQ says compliance is required immediately.
ISO date strings compare correctly as strings, which is why asOf >= ENFORCEMENT_FROM needs no date parsing. Pass the date in explicitly; a compliance engine that reads the system clock cannot be tested or replayed.
Step 5: Coaches Hidden Under Other Titles
Section 7 of the guide is the one most gyms will trip on. It says that when an outlet fails the rate, or assigns any task of a localised occupation to a non-Saudi worker, directly or indirectly, under any other job title, the ministry may take every measure needed to enforce the decision.
Your roster cannot show that. Your timetable can. A receptionist who teaches the 7 pm spin class, or a "sales consultant" who runs personal-training sessions, appears in the booking system as an instructor. Join the two.
Create src/disguised.ts:
import { isTargetedOccupation } from './rules';
import { Outlet, OutletAssessment, Worker, assessOutlet } from './outlet';
/** One coaching act from the class timetable or the PT booking system. */
export interface Session {
licenceNo: string;
instructorId: string;
}
/**
* Non-Saudis registered outside the 12 codes who nonetheless lead sessions.
* Section 7 of the guide treats giving them a targeted job's tasks, under
* any other title, as a breach.
*/
export function disguisedCoaches(outlet: Outlet, sessions: Session[]): Worker[] {
const leading = new Set(
sessions.filter((s) => s.licenceNo === outlet.licenceNo).map((s) => s.instructorId),
);
return outlet.workers.filter(
(w) => !w.saudi && !isTargetedOccupation(w.occupationCode) && leading.has(w.id),
);
}
/** Re-assess as an inspector would if each disguised coach counted as a sports coach. */
export function exposure(outlet: Outlet, sessions: Session[], asOf: string): OutletAssessment {
const flagged = new Set(disguisedCoaches(outlet, sessions).map((w) => w.id));
const workers = outlet.workers.map((w) =>
flagged.has(w.id) ? { ...w, occupationCode: '342201' } : w,
);
return assessOutlet({ ...outlet, workers }, asOf);
}exposure answers the question an inspector would ask: if these people were counted as what they actually do, would the outlet still pass? Counting them as 342201 (sports coach) is our reading, not the guide's, and it is stated in the code. It only needs to be right enough to show whether the outlet changes status.
Two caveats. A Saudi leading classes outside a coaching code is not flagged, because the breach is giving the tasks to a non-Saudi. And a session record is evidence, not proof: an occasional cover class may have an explanation. The output is a list to review, not a verdict.
Step 6: Replacement, Hiring and the Shrinking Denominator
When an outlet is short, there are two ways to close the gap, and they are different numbers. Replacing a non-Saudi in a targeted seat raises the numerator only. Hiring a Saudi on top raises both, and at some headcounts the extra worker pushes the requirement up by one.
The same logic runs backwards. A Saudi leaving an outlet also shrinks its denominator, so an outlet with 20 targeted workers and 5 Saudis can spare two, not three, while still passing.
Create src/gap.ts:
import { requiredSaudis } from './required';
export interface Remediation {
/** Replace non-Saudis in targeted seats: the denominator stays put. */
byReplacement: number;
/** Add Saudis on top: every hire also grows the denominator. */
byHiringOnly: number;
}
export function remediation(targeted: number, saudis: number): Remediation {
const byReplacement = Math.max(0, requiredSaudis(targeted) - saudis);
let byHiringOnly = 0;
while (saudis + byHiringOnly < requiredSaudis(targeted + byHiringOnly)) byHiringOnly++;
return { byReplacement, byHiringOnly };
}
/**
* How many Saudis in targeted seats could leave this outlet with it still
* meeting the rate. Each departure shrinks the denominator as well.
*/
export function spareSaudis(targeted: number, saudis: number): number {
let k = 0;
while (k < saudis && saudis - (k + 1) >= requiredSaudis(targeted - (k + 1))) k++;
return k;
}The case that surprises people is 16. An outlet with 16 targeted workers needs 2 Saudis (2.4 rounds down). With 1, the gap is one. Hire one Saudi and there are 17 targeted workers, which need 3 (2.55 rounds up), so the outlet is still one short. remediation(16, 1) returns one replacement or two hires. The guide's table shows the same edges: 16 to 17, 23 to 24, 29 to 30, 36 to 37.
Step 7: Planning Across the Chain
A chain with surplus Saudi coaches at one branch and a gap at another can often fix part of the problem by moving people before it recruits. The planner below is greedy: it takes the outlet with the largest gap, finds the outlet with the most spare Saudis, moves one, assesses both again and repeats. Whatever transfers cannot cover is priced as replacements or hires.
Create src/chain.ts:
import { isTargetedOccupation } from './rules';
import { Outlet, assessOutlet } from './outlet';
import { Remediation, remediation, spareSaudis } from './gap';
export interface Move {
workerId: string;
from: string;
to: string;
}
export interface ChainPlan {
moves: Move[];
stillShort: Array<{ licenceNo: string } & Remediation>;
}
const clone = (outlets: Outlet[]): Outlet[] =>
outlets.map((o) => ({ ...o, workers: o.workers.map((w) => ({ ...w })) }));
/**
* Greedy plan: move a spare Saudi from the outlet with most room to the
* outlet with the largest gap, re-assess both, repeat. Whatever is left
* is priced as replacement or hiring.
*/
export function planChain(input: Outlet[], asOf: string): ChainPlan {
const outlets = clone(input);
const moves: Move[] = [];
const saudiSeats = outlets.flatMap((o) => o.workers).filter((w) => w.saudi).length;
// Each Saudi moves at most once per pass; the cap makes termination obvious.
for (let pass = 0; pass < saudiSeats; pass++) {
const rows = outlets.map((o) => ({ o, a: assessOutlet(o, asOf) }));
const short = rows
.filter((r) => r.a.status === 'SHORT')
.sort((x, y) => y.a.gap - x.a.gap)[0];
if (!short) break;
const donor = rows
.filter((r) => r.o !== short.o && r.a.status !== 'OUT_OF_SCOPE_ACTIVITY')
.map((r) => ({ ...r, spare: spareSaudis(r.a.targeted, r.a.saudis) }))
.filter((r) => r.spare > 0)
.sort((x, y) => y.spare - x.spare)[0];
if (!donor) break;
const i = donor.o.workers.findIndex((w) => w.saudi && isTargetedOccupation(w.occupationCode));
const [worker] = donor.o.workers.splice(i, 1);
short.o.workers.push(worker);
moves.push({ workerId: worker.id, from: donor.o.licenceNo, to: short.o.licenceNo });
}
const stillShort = outlets
.map((o) => assessOutlet(o, asOf))
.filter((a) => a.status === 'SHORT')
.map((a) => ({ licenceNo: a.licenceNo, ...remediation(a.targeted, a.saudis) }));
return { moves, stillShort };
}The planner never mutates its input, and it only takes from a donor what spareSaudis says the donor can lose. Greedy is not optimal for every chain, but for the sizes a gym chain has, it gives a plan a manager can read line by line, which matters more.
Check before you move anyone. The guide does not say whether moving a worker between two Ministry of Sport licences under the same commercial registration needs a transaction in Qiwa, or how fast the move shows in the count. Treat a move as a planning option and confirm the mechanics with your Qiwa account before relying on it.
Step 8: Running It on a Three-Branch Chain
The demo models a chain with three outlets. RYD-01 has 16 targeted workers and one Saudi supervisor. RYD-02 is a women's centre with 20 targeted workers, five of them Saudi. JED-01 is licensed as a retail sports-goods shop (477310) but runs a men's gym floor, with nine targeted workers, one Saudi, two receptionists, and a timetable showing one receptionist leading a class.
Create src/demo.ts:
import { Outlet, assessOutlet } from './outlet';
import { Session, disguisedCoaches, exposure } from './disguised';
import { planChain } from './chain';
const staff = (prefix: string, code: string, n: number, saudi: boolean) =>
Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i + 1}`, occupationCode: code, saudi }));
const chain: Outlet[] = [
{
licenceNo: 'RYD-01', name: 'Riyadh North (men)',
registeredActivity: '931221', actualActivities: ['931221'],
workers: [
...staff('r1-pt-', '342302', 10, false),
...staff('r1-fit-', '342301', 5, false),
...staff('r1-sup-', '342221', 1, true),
...staff('r1-desk-', '422601', 3, false),
],
},
{
licenceNo: 'RYD-02', name: 'Riyadh East (women)',
registeredActivity: '931222', actualActivities: ['931222'],
workers: [
...staff('r2-fit-', '342301', 15, false),
...staff('r2-sa-', '342201', 5, true),
],
},
{
licenceNo: 'JED-01', name: 'Jeddah Corniche',
registeredActivity: '477310', actualActivities: ['477310', '931221'],
workers: [
...staff('j1-pt-', '342302', 8, false),
...staff('j1-sa-', '342201', 1, true),
...staff('j1-desk-', '422601', 2, false),
],
},
];
const sessions: Session[] = [
{ licenceNo: 'JED-01', instructorId: 'j1-desk-1' },
{ licenceNo: 'JED-01', instructorId: 'j1-pt-1' },
];
const asOf = '2026-11-18';
for (const o of chain) {
const a = assessOutlet(o, asOf);
console.log(`${a.licenceNo} ${a.status.padEnd(9)} targeted=${a.targeted} saudis=${a.saudis} required=${a.required} gap=${a.gap}${a.scopeFromActualActivity ? ' (in scope by actual activity)' : ''}`);
}
const jed = chain[2];
const hidden = disguisedCoaches(jed, sessions).map((w) => w.id);
const exp = exposure(jed, sessions, asOf);
console.log(`JED-01 disguised coaches: ${hidden.join(', ')} -> ${exp.status} required=${exp.required} gap=${exp.gap}`);
const plan = planChain(chain, asOf);
for (const m of plan.moves) console.log(`move ${m.workerId}: ${m.from} -> ${m.to}`);
console.log('still short:', JSON.stringify(plan.stillShort));Run it with npx tsx src/demo.ts:
RYD-01 SHORT targeted=16 saudis=1 required=2 gap=1
RYD-02 COMPLIANT targeted=20 saudis=5 required=3 gap=0
JED-01 COMPLIANT targeted=9 saudis=1 required=1 gap=0 (in scope by actual activity)
JED-01 disguised coaches: j1-desk-1 -> SHORT required=2 gap=1
move r2-sa-1: RYD-02 -> RYD-01
move r2-sa-2: RYD-02 -> RYD-01
still short: []Three findings a spreadsheet would miss. RYD-01 needs two transfers to close a gap of one, because the first one raises its requirement from two to three. JED-01 is in scope even though its registration says retail. And JED-01 passes on paper but fails as soon as the receptionist who teaches is counted as a coach.
Testing Your Implementation
The guide gives you something rare: a printed table you can pin as a fixture. Transcribe it in full and test every column, not a sample. The other tests cover the threshold, the actual-activity rule, the enforcement date boundary, the shrinking denominator and the chain planner.
Create tests/engine.test.ts:
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { TARGETED_ACTIVITIES, TARGETED_OCCUPATIONS } from '../src/rules';
import { requiredSaudis } from '../src/required';
import { Outlet, assessOutlet } from '../src/outlet';
import { remediation, spareSaudis } from '../src/gap';
import { disguisedCoaches, exposure } from '../src/disguised';
import { planChain } from '../src/chain';
// Guide table 3, transcribed: targeted workers 4..40 -> Saudis required.
const TABLE_3: Array<[number, number]> = [
[4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [9, 1],
[10, 2], [11, 2], [12, 2], [13, 2], [14, 2], [15, 2], [16, 2],
[17, 3], [18, 3], [19, 3], [20, 3], [21, 3], [22, 3], [23, 3],
[24, 4], [25, 4], [26, 4], [27, 4], [28, 4], [29, 4],
[30, 5], [31, 5], [32, 5], [33, 5], [34, 5], [35, 5], [36, 5],
[37, 6], [38, 6], [39, 6], [40, 6],
];
const staff = (prefix: string, code: string, n: number, saudi: boolean) =>
Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i + 1}`, occupationCode: code, saudi }));
const outlet = (licenceNo: string, targeted: number, saudis: number, extra = 0): Outlet => ({
licenceNo, name: licenceNo, registeredActivity: '931221', actualActivities: ['931221'],
workers: [
...staff(`${licenceNo}-n`, '342301', targeted - saudis, false),
...staff(`${licenceNo}-s`, '342201', saudis, true),
...staff(`${licenceNo}-x`, '241101', extra, false),
],
});
test('reference tables have the shape the guide prints', () => {
const codes = Object.keys(TARGETED_OCCUPATIONS);
assert.equal(codes.length, 12);
assert.ok(codes.every((c) => /^342[23]\d{2}$/.test(c)), 'codes are six digits in 3422xx or 3423xx');
assert.deepEqual(Object.keys(TARGETED_ACTIVITIES).sort(), ['931221', '931222']);
});
test('requiredSaudis reproduces guide table 3 for every printed column', () => {
for (const [targeted, saudis] of TABLE_3) assert.equal(requiredSaudis(targeted), saudis, `at ${targeted}`);
});
test('below four targeted workers nothing is required', () => {
assert.equal(requiredSaudis(0), 0);
assert.equal(requiredSaudis(3), 0);
assert.throws(() => requiredSaudis(2.5), RangeError);
});
test('guide example 1: 23 staff, 10 in targeted jobs, 2 Saudis required', () => {
const o: Outlet = {
licenceNo: 'A', name: 'A', registeredActivity: '931221', actualActivities: ['931221'],
workers: [
...staff('sales', '122101', 2, false), ...staff('coach', '342201', 6, false),
...staff('fit', '342301', 4, false), ...staff('acct', '241101', 3, false),
...staff('desk', '422601', 4, false), ...staff('guard', '541401', 4, false),
],
};
const a = assessOutlet(o, '2026-11-18');
assert.equal(o.workers.length, 23);
assert.deepEqual([a.status, a.targeted, a.required, a.gap], ['SHORT', 10, 2, 2]);
});
test('threshold counts targeted workers, not headcount', () => {
const a = assessOutlet(outlet('B', 3, 0, 20), '2026-11-18');
assert.equal(a.status, 'BELOW_THRESHOLD');
});
test('scope follows actual activity even when the registration says otherwise', () => {
const o = { ...outlet('C', 6, 0), registeredActivity: '477310', actualActivities: ['477310', '931222'] };
const a = assessOutlet(o, '2026-11-18');
assert.equal(a.status, 'SHORT');
assert.equal(a.scopeFromActualActivity, true);
const out = assessOutlet({ ...o, actualActivities: ['477310'] }, '2026-11-18');
assert.equal(out.status, 'OUT_OF_SCOPE_ACTIVITY');
});
test('penalties start on 18 November 2026, not the day before', () => {
assert.equal(assessOutlet(outlet('D', 10, 0), '2026-11-17').enforceable, false);
assert.equal(assessOutlet(outlet('D', 10, 0), '2026-11-18').enforceable, true);
});
test('at 16 targeted, one hire does not close a gap of one', () => {
assert.deepEqual(remediation(16, 1), { byReplacement: 1, byHiringOnly: 2 });
assert.deepEqual(remediation(10, 0), { byReplacement: 2, byHiringOnly: 2 });
assert.deepEqual(remediation(20, 3), { byReplacement: 0, byHiringOnly: 0 });
});
test('spare Saudis account for the shrinking denominator', () => {
assert.equal(spareSaudis(20, 5), 2);
assert.equal(spareSaudis(17, 3), 1);
assert.equal(spareSaudis(9, 1), 0);
assert.equal(spareSaudis(4, 1), 1);
});
test('a non-Saudi receptionist who leads classes is flagged and changes the result', () => {
const o = outlet('E', 9, 1);
o.workers.push({ id: 'desk1', occupationCode: '422601', saudi: false });
o.workers.push({ id: 'desk2', occupationCode: '422601', saudi: true });
const sessions = [
{ licenceNo: 'E', instructorId: 'desk1' },
{ licenceNo: 'E', instructorId: 'desk2' },
{ licenceNo: 'OTHER', instructorId: 'E-n1' },
];
assert.deepEqual(disguisedCoaches(o, sessions).map((w) => w.id), ['desk1']);
assert.equal(assessOutlet(o, '2026-11-18').status, 'COMPLIANT');
const x = exposure(o, sessions, '2026-11-18');
assert.deepEqual([x.status, x.targeted, x.required], ['SHORT', 10, 2]);
});
test('chain plan needs two moves to close one gap at 16, and leaves the donor compliant', () => {
const plan = planChain([outlet('R1', 16, 1), outlet('R2', 20, 5)], '2026-11-18');
assert.equal(plan.moves.length, 2);
assert.ok(plan.moves.every((m) => m.from === 'R2' && m.to === 'R1'));
assert.deepEqual(plan.stillShort, []);
});
test('chain plan prices what transfers cannot cover and never mutates its input', () => {
const input = [outlet('S1', 12, 0), outlet('S2', 9, 1)];
const plan = planChain(input, '2026-11-18');
assert.deepEqual(plan.moves, []);
assert.deepEqual(plan.stillShort, [{ licenceNo: 'S1', byReplacement: 2, byHiringOnly: 2 }]);
assert.equal(input[1].workers.length, 9);
});Run with npx tsx --test tests/engine.test.ts. All twelve pass. We also mutated the engine seven ways (rounding up instead of half up, a threshold of five, ignoring actual activity, enforcement a day late, hiring that ignores the denominator, departures that ignore it, flagging Saudis as disguised coaches), and each mutation fails at least one test.
Troubleshooting
The chain total passes but branches fail. Expected. The decision is counted per licence. Pooling the chain's coaches into one number is the most common way to report compliance that does not exist.
An outlet comes back BELOW_THRESHOLD with forty staff. Correct if fewer than four of them are registered in the twelve codes. Then run Step 5: forty staff and three registered coaches usually means the timetable has more instructors than the contracts do.
Every worker comes back as not targeted. You are reading internal HR titles instead of the occupation code on the Qiwa contract. They drift apart, and the drift is itself the finding.
Hiring was approved but the outlet is still short. Check the edge counts in Step 6. At 16, 23, 29 and 36 targeted workers, a net new hire raises the requirement as well.
Next Steps
- Run the assessment nightly per licence and alert on any change of status, not only on SHORT. An outlet moving from BELOW_THRESHOLD to SHORT because a fourth trainer joined is the alert that matters.
- Pull rosters from the platform instead of a spreadsheet; Qiwa integration for HR systems covers where authenticated contract data comes from.
- Keep the entity-wide view alongside this one. The guide notes that Nitaqat and other occupation decisions still apply; its own worked example lists three accountants, who fall under the accounting Saudization engine. The Nitaqat calculator shows the company-wide band, which says nothing about the 15% at each branch.
- When a breach does show up, the first visible symptom is often a Saudization certificate refused in Qiwa.
Conclusion
The gym decision looks like one percentage. In practice it is a count made per licence, over twelve codes, from contract data that rarely matches the timetable, rounded with edges where one hire is not enough. An engine that counts per outlet, tests itself against the guide's own table, reads the timetable for disguised coaches and plans transfers before hires will tell a chain where it stands before 18 November rather than after an inspection.
If you run several branches and want this check wired to your Qiwa and booking data instead of rebuilt by hand, book a free diagnostic session. We will look at where your occupation codes, licences and class schedules live, and where they stop agreeing.