writing/tutorial/2026/11
● TutorialNov 11, 2026·18 min read

Housing Supervisor Saudization: a TypeScript Compliance Check

Ministerial Decision 62298 reserves the housing supervisor occupation (code 515102) for Saudis in every establishment that runs collective housing for 20 or more people, with a SAR 5,000 GOSI wage floor. Build the check in TypeScript: site scope, title versus actual work, the wage floor, and an action plan HR can act on.

Since 1 February 2026, only Saudis may work as housing supervisors in Saudi Arabia. Ministerial Decision 62298 of 31 October 2025, issued by the Ministry of Human Resources and Social Development (HRSD) together with the Ministry of Municipalities and Housing, sets the rate at 100% for occupation code 515102. It covers every establishment that manages or operates collective housing for 20 or more people. A Saudi supervisor only counts if the wage registered with GOSI is at least SAR 5,000 a month.

Most quota rules are about a percentage. This one is not. At 100% there is no ratio to compute, so the risk moves elsewhere: to the site that grew past 20 residents last month, to the camp boss who has a different job title in Qiwa, and to the Saudi supervisor registered with GOSI at SAR 4,500. This tutorial builds the check that finds those three cases, for the HR or systems developer at a contractor, a facilities company or a labour-housing operator.

Prerequisites

  • Node.js 20 or later and TypeScript 5 with strict on
  • An export of your workers from your HR system: nationality, Qiwa occupation code, GOSI contributory wage
  • A list of your housing sites with the number of people living in each
  • Familiarity with how Saudization decisions work. If this is your first one, read our gym outlet engine first

What You'll Build

A small module with no dependencies that answers four questions on any date:

  1. Is the establishment in scope at all, and through which sites?
  2. Who is a housing supervisor for the decision, by title, by actual work, or both?
  3. What is wrong with each of them: nationality, wage, or only the registered title?
  4. What does HR have to do: how many Saudis to hire, whose duties to take away, how much to add to registered wages, and which titles to fix in Qiwa?

The code follows the same design as our engineering technician quota engine: the decision is data, each rule is one small function, and the tests use the guide's own thresholds.

Step 1: What the Guide Actually Says

We read the decision and its procedural guide on the HRSD website. The facts the code depends on:

RuleSource
Housing supervisor, code 515102, reserved for Saudis at 100%Guide 3.1 and table 1
Applies to establishments that manage or operate collective housing for 20 or more beneficiaries in shared housing: residential compounds, mobile caravans, residential buildingsGuide 3.2
A Saudi counts only with a monthly GOSI contributory wage of SAR 5,000 or moreGuide 3.3
A supervisor paid below that floor is a violation, not just "not counted"Guide, FAQ
Issued 31 October 2025, three-month grace period, applied from 1 February 2026Guide 3.5
Applies to job titles and to the actual workGuide, FAQ
Giving any of the occupation's duties to a non-Saudi, directly or indirectly, under any other title, is a breachGuide 5
Penalties follow Ministerial Decisions 75913 (19/05/1445) and 44558 (03/04/1446)Guide 5 and decision, article 4
Applies whatever your Nitaqat bandGuide, FAQ

Two things the guide does not say. It does not say every site must have a supervisor: it says who may hold the job, not that the job must exist. And it does not say how the 20 beneficiaries are counted across several small sites. We read "20 or more in shared housing" as per site, and the code puts that reading in one place so you can change it.

Step 2: The Decision as Data

Everything the guide fixes goes into one record. When the ministry amends the wage floor, you change one number.

/** Ministerial Decision 62298 of 31 October 2025 and its procedural guide. */
export const HOUSING_SUPERVISOR = {
  decision: '62298',
  issued: '2025-10-31',
  enforcedFrom: '2026-02-01', // issue date plus a three-month grace period
  occupationCode: '515102', // "Housing supervisor" (مشرف مسكن)
  saudiRatePercent: 100,
  minGosiWageSar: 5000, // contributory wage registered with GOSI
  minBeneficiaries: 20, // collective housing for 20 or more people
} as const;
 
/** One collective housing site: a compound, a building, a set of caravans. */
export interface HousingSite {
  readonly id: string;
  readonly beneficiaries: number; // people housed there today
}
 
export interface Worker {
  readonly id: string;
  readonly saudi: boolean;
  readonly occupationCode: string; // as registered in Qiwa
  readonly gosiWageSar: number; // integer riyals, as registered with GOSI
  /** Sites this person supervises in practice, whatever the job title says. */
  readonly supervisesSiteIds: readonly string[];
}

supervisesSiteIds is the field that matters most and the one your HR system does not have. Qiwa knows job titles. It does not know who actually hands out room keys, runs the headcount and signs the cleaning sheet at the Jubail compound. Someone has to record that, site by site. In our experience the site managers know in five minutes.

Step 3: Which Sites Bring You Into Scope

/** Sites that bring the establishment under the decision. */
export function sitesInScope(sites: readonly HousingSite[]): HousingSite[] {
  return sites.filter((s) => s.beneficiaries >= HOUSING_SUPERVISOR.minBeneficiaries);
}
 
/** On or after the enforcement date? ISO dates compare correctly as strings. */
export function isEnforced(onDate: string): boolean {
  return onDate >= HOUSING_SUPERVISOR.enforcedFrom;
}

Note the >=. The guide says 20 or more, so a site with exactly 20 residents is in scope. This is exactly the off-by-one that a quick implementation gets wrong, and the first test checks it.

Run this on your occupancy plan as well as today's numbers. A site at 18 residents that takes two new workers next week brings the whole establishment under the decision.

Step 4: Title, Actual Work, or Both

The guide applies the decision to the title and to the work. So a person is a housing supervisor if Qiwa says so, if they do the job, or both. The checks that follow differ by case.

export type RoleSource = 'title' | 'actual-work' | 'both';
 
/** Is this person a housing supervisor for the decision, and on what evidence? */
export function supervisorRole(w: Worker): RoleSource | null {
  const byTitle = w.occupationCode === HOUSING_SUPERVISOR.occupationCode;
  const byWork = w.supervisesSiteIds.length > 0;
  if (byTitle && byWork) return 'both';
  if (byTitle) return 'title';
  if (byWork) return 'actual-work';
  return null;
}
 
export type Finding =
  | { kind: 'non-saudi'; workerId: string; role: RoleSource }
  | { kind: 'wage-below-floor'; workerId: string; shortfallSar: number }
  | { kind: 'title-mismatch'; workerId: string };
 
/** Everything wrong with one worker. An empty list means this person is fine. */
export function checkWorker(w: Worker): Finding[] {
  const role = supervisorRole(w);
  if (role === null) return [];
  if (!w.saudi) return [{ kind: 'non-saudi', workerId: w.id, role }];
  const out: Finding[] = [];
  const shortfall = HOUSING_SUPERVISOR.minGosiWageSar - w.gosiWageSar;
  if (shortfall > 0) out.push({ kind: 'wage-below-floor', workerId: w.id, shortfallSar: shortfall });
  if (role === 'actual-work') out.push({ kind: 'title-mismatch', workerId: w.id });
  return out;
}

The three outcomes match three different conversations with HR:

  • A non-Saudi in the role is a breach, whatever the title. A "camp coordinator" or "site administrator" who does the supervisor's work is the case the guide names in section 5.
  • A Saudi below SAR 5,000 is a breach too. The FAQ answers this directly. The shortfall in riyals goes in the finding, because that is the number payroll needs.
  • A Saudi doing the work under another title is not a breach of the 100% rule, but Qiwa will not count them as a housing supervisor, so the official picture and the real one disagree. The fix is a title change, not a new hire.

Wage means the contributory wage registered with GOSI, not the gross pay on your payroll report. It is the basic wage plus the housing allowance, so overtime, commissions and other allowances on the payslip do not help. The GOSI contribution engine shows how the two drift apart.

Step 5: Assessing the Establishment

export interface Assessment {
  readonly onDate: string;
  readonly inScope: boolean;
  readonly enforced: boolean;
  readonly sitesInScope: readonly string[];
  readonly findings: readonly Finding[];
  /** In-scope sites that no compliant Saudi supervises. Information, not a breach. */
  readonly sitesWithoutCompliantSupervisor: readonly string[];
  readonly compliant: boolean;
}
 
export function assess(
  onDate: string,
  sites: readonly HousingSite[],
  workers: readonly Worker[],
): Assessment {
  const scoped = sitesInScope(sites).map((s) => s.id);
  const inScope = scoped.length > 0;
  const findings = inScope ? workers.flatMap(checkWorker) : [];
 
  const breached = new Set(
    findings.filter((f) => f.kind !== 'title-mismatch').map((f) => f.workerId),
  );
  // A non-Saudi supervisor always has a finding, so "not breached" means a compliant Saudi.
  const covered = new Set(
    workers.filter((w) => !breached.has(w.id)).flatMap((w) => w.supervisesSiteIds),
  );
 
  return {
    onDate,
    inScope,
    enforced: isEnforced(onDate),
    sitesInScope: scoped,
    findings,
    sitesWithoutCompliantSupervisor: scoped.filter((id) => !covered.has(id)),
    compliant: findings.every((f) => f.kind === 'title-mismatch'),
  };
}

Three design choices are worth explaining.

Out of scope means no findings. If no site reaches 20 residents, the decision does not apply, so the function returns an empty list rather than findings you would have to filter out later.

Title mismatches do not make you non-compliant. They are listed so you fix them, but compliant only looks at nationality and wage. Mixing the two would send you looking for a hire you do not need.

sitesWithoutCompliantSupervisor is information, not a breach. The guide does not require a supervisor per site. But a large site where nobody compliant does the job usually means someone non-compliant does it, and that is what you are looking for. Treat the list as a question to ask the site manager.

Step 6: From Findings to an Action Plan

HR does not act on findings. It acts on a hiring number, a payroll figure and a list of names.

export interface ActionPlan {
  /** Saudi supervisors to recruit, one per non-Saudi doing the job today. */
  readonly saudiHires: number;
  /** Non-Saudis whose supervision duties must stop, whatever their title. */
  readonly dutiesToReassign: readonly string[];
  /** Monthly increase in registered GOSI wages, in riyals. */
  readonly wageTopUpSarPerMonth: number;
  /** Saudis to re-register in Qiwa under code 515102. */
  readonly qiwaTitleChanges: readonly string[];
}
 
export function actionPlan(a: Assessment): ActionPlan {
  const nonSaudi = a.findings.filter((f) => f.kind === 'non-saudi');
  let topUp = 0;
  for (const f of a.findings) if (f.kind === 'wage-below-floor') topUp += f.shortfallSar;
  return {
    saudiHires: nonSaudi.length,
    dutiesToReassign: nonSaudi.map((f) => f.workerId),
    wageTopUpSarPerMonth: topUp,
    qiwaTitleChanges: a.findings
      .filter((f) => f.kind === 'title-mismatch')
      .map((f) => f.workerId),
  };
}

Each non-Saudi in the role needs a Saudi to take over the work, and their own duties must stop, even if they keep their job under a genuinely different role. The wage top-up is the monthly increase in registered wages that brings every Saudi supervisor to SAR 5,000. Employer and employee GOSI contributions rise with it, so pass the figure to whoever owns the contribution calculation.

Testing Your Implementation

The tests use the guide's thresholds at their edges: 19 and 20 residents, SAR 4,999 and SAR 5,000, 31 January and 1 February 2026.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { actionPlan, assess, checkWorker, type HousingSite, type Worker } from './engine.ts';
 
const sites: HousingSite[] = [
  { id: 'jubail-a', beneficiaries: 240 },
  { id: 'jubail-b', beneficiaries: 20 },
  { id: 'dammam-villa', beneficiaries: 19 },
];
 
function w(id: string, over: Partial<Worker> = {}): Worker {
  return { id, saudi: true, occupationCode: '515102', gosiWageSar: 5000,
    supervisesSiteIds: [], ...over };
}
 
test('scope starts at 20 beneficiaries, not 21', () => {
  const a = assess('2026-11-11', sites, []);
  assert.deepEqual(a.sitesInScope, ['jubail-a', 'jubail-b']);
  assert.equal(a.inScope, true);
});
 
test('an establishment with only small sites is out of scope', () => {
  const a = assess('2026-11-11', [{ id: 'v', beneficiaries: 19 }],
    [w('x', { saudi: false })]);
  assert.equal(a.inScope, false);
  assert.equal(a.findings.length, 0);
  assert.equal(a.compliant, true);
});
 
test('SAR 5,000 exactly counts; SAR 4,999 does not', () => {
  assert.deepEqual(checkWorker(w('ok')), []);
  assert.deepEqual(checkWorker(w('low', { gosiWageSar: 4999 })),
    [{ kind: 'wage-below-floor', workerId: 'low', shortfallSar: 1 }]);
});
 
test('a non-Saudi doing the work under another title is a breach', () => {
  const f = checkWorker(w('n', { saudi: false, occupationCode: '962101',
    supervisesSiteIds: ['jubail-a'] }));
  assert.deepEqual(f, [{ kind: 'non-saudi', workerId: 'n', role: 'actual-work' }]);
});
 
test('a Saudi doing the work under another title is a title fix, not a breach', () => {
  const a = assess('2026-11-11', sites,
    [w('s', { occupationCode: '432101', supervisesSiteIds: ['jubail-a', 'jubail-b'] })]);
  assert.equal(a.compliant, true);
  assert.deepEqual(actionPlan(a).qiwaTitleChanges, ['s']);
  assert.deepEqual(a.sitesWithoutCompliantSupervisor, []);
});
 
test('the plan adds up hires, duties and wage top-ups', () => {
  const a = assess('2026-11-11', sites, [
    w('a', { gosiWageSar: 4000, supervisesSiteIds: ['jubail-a'] }),
    w('b', { saudi: false, supervisesSiteIds: ['jubail-b'] }),
    w('c', { gosiWageSar: 4500 }),
  ]);
  assert.equal(a.compliant, false);
  assert.deepEqual(a.sitesWithoutCompliantSupervisor, ['jubail-a', 'jubail-b']);
  assert.deepEqual(actionPlan(a), { saudiHires: 1, dutiesToReassign: ['b'],
    wageTopUpSarPerMonth: 1500, qiwaTitleChanges: [] });
});
 
test('enforcement starts on 1 February 2026', () => {
  assert.equal(assess('2026-01-31', sites, []).enforced, false);
  assert.equal(assess('2026-02-01', sites, []).enforced, true);
});

Run them with:

npx tsx --test engine.test.ts

We also changed each comparison in the engine on purpose, for example >= to > on the resident count and on the date. Every such change fails at least one test, so the tests really do pin the rules.

Troubleshooting

The check finds supervisors HR has never heard of. That is the actual-work rule doing its job. Look at who supervises those sites in practice and what title they have in Qiwa. Very often it is a foreman or driver who took the job over gradually.

A Saudi supervisor shows a shortfall although payroll pays more than SAR 5,000. The registered GOSI wage is lower than the paid wage. Update the registration. The guide counts the GOSI figure, not your payslip.

The establishment drops in and out of scope. One site sits near 20 residents. Decide on a margin, for example treat 18 or more as in scope, and keep the decision record unchanged so the legal threshold stays visible.

Your Nitaqat band is green but this check fails. Both can be true. The guide says the occupation decision applies whatever the band. Our Nitaqat calculator answers the entity-wide question, and this module answers the occupation one.

Next Steps

Conclusion

The housing supervisor decision is short, and that is the trap. With a 100% rate there is no ratio to watch, so a company can be in breach through a single site that grew, one foreman with the wrong title, or a GOSI registration a few hundred riyals too low. The module above turns those three cases into a list of names and three numbers HR can act on, and it runs on any date you give it.

If you would rather have this check run automatically against your Qiwa, GOSI and site occupancy data 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.