Fair resume matching: stripping protected attributes before AI ever sees a profile

How DEV3 AI Job Board & Resume Matcher keeps names, contact details and protected attributes away from the Google Gemini API, anchors every AI score to a transparent rule score, and never lets a score move a candidate.

DSDilawar Singh 10 min read 1 views
Fair resume matching: stripping protected attributes before AI ever sees a profile

Resume matching is the AI feature every job board wants and the one most likely to cause harm. A language model that reads "Priya, 52, mother of two, Indian national" alongside a list of React projects has been given every piece of information an employer is not allowed to decide on. Telling the model "please ignore protected characteristics" is not a control, and a score that quietly filters people out of a list is an automated hiring decision. When we built DEV3 AI Job Board & Resume Matcher, our WordPress job board with a hiring pipeline, we treated fairness as a data-flow problem: what the model is never given cannot influence it. This article explains the four layers that make that true, and the tests that check it.

An application's match card with criteria and the decision-support notice A match card: the score, four criteria, plain-language strengths, and the notice that scores never move or reject anyone.

What it does and who it's for

DEV3 AI Job Board & Resume Matcher is a complete job board in one plugin: listings with Google for Jobs markup, company pages, paid job packages, employer and candidate dashboards, private resumes, job alerts and a kanban hiring pipeline. It suits:

  • Niche job boards (a region, an industry, remote-only roles) that sell job posts through Stripe or WooCommerce.
  • Company careers pages where HR teams want a pipeline with stages, notes and ratings without paying for a per-seat ATS.
  • Recruitment agencies running a board for several client companies, each managed by its own employer account.
  • Universities and community organisations posting internships and volunteer roles, where guest applications with a resume file are enough.
  • Hiring teams that want help reading applications but need to show that scoring never uses personal characteristics.

All AI features have non-AI fallbacks, and a score is always decision support only.

How to set it up

  1. Install the plugin zip from the bundle download and activate it. Five pages are created, from Jobs to Candidate Dashboard.
  2. Optional: add a Google Gemini API key in DEV3 AI Job Board → API Config and click Validate & save.
  3. In Settings, choose moderation, guest applications and emails, and in its Fairness section decide whether employers see scores and whether Blind screening is on.
  4. Add categories, job types, locations and skills in Categories & types.
  5. Create packages and choose a payment gateway in Packages & payments.

Packages and payments setup Packages with job posts, days live and featured placement, and the payment gateway.

How to use it

Post a job

Employers use Post a Job: details, then Build outline to turn notes into a structured ad, Check wording for exclusionary phrases, Preview job, and a package.

Read applications in the pipeline

In Applications, pick a job, click Score all, and turn on Best match first. Cards move between New, Screening, Interview, Offer, Hired and Not a fit by drag and drop or with Move to. Opening a card shows the match breakdown, strengths and gaps.

The hiring pipeline with match chips The pipeline: scores sort the board, but every application stays visible and only people change stages.

Fill a profile from a resume

Candidates upload a PDF, DOCX or TXT resume in Candidate Dashboard → Profile. The fields fill in for checking, and they click Save profile.

The rest of this article is about what happens between "Score all" and the numbers on those cards.

Layer 1: The parser cannot produce protected data

Most protected data enters a job board through the resume. A parsed profile usually becomes the input for everything after it, so the first control is the shape of the parser's output. The resume-parsing response schema has fields for headline, summary, location, skills, experience (title, company, dates, summary), education and links, and nothing else. There is no age, gender, nationality, photo or even name property for a model to fill.

A test makes that a contract rather than a habit:

$resume = wp_json_encode( \Dev3\AiJobBoard\Ai\Prompts_Library::resume_schema() );
foreach ( array( 'gender', 'age', 'birth', 'nationality', 'photo', 'marital', 'religion', '"name"', 'email', 'phone' ) as $bad ) {
	$this->assertStringNotContainsString( $bad, $resume );
}

(from AiTest::test_prompts_are_registered_with_bounded_schemas)

The same test checks that every free-text string in the parser, writer and match schemas has a maxLength, so no field can turn into an essay.

When there is no API key, the parser reads text locally instead: Text_Extractor pulls text from PDF content streams (including Flate-compressed ones) and from a .docx file's word/document.xml in plain PHP, with no shell tools. A heuristic parser then matches skills against a vocabulary and finds experience and education by section headings. A second test runs a fixture PDF that contains an email, a phone number, a gender, a nationality and a date of birth through this rule parser, and asserts that none of them reach the profile.

Layer 2: Strip by key, at every depth

Profiles also come from people typing in forms, from guest applications and from filters other plugins add. So before any matching, the profile passes through Matcher::strip_protected():

public static function strip_protected( array $data, $depth = 0 ) {
	$keys = self::protected_keys();
	$out  = array();
	foreach ( $data as $key => $value ) {
		if ( is_string( $key ) ) {
			$norm = preg_replace( '/[^a-z]/', '', strtolower( $key ) );
			if ( in_array( $norm, $keys, true ) && ! ( $depth > 0 && in_array( $norm, self::NESTED_ALLOWED, true ) ) ) {
				continue;
			}
		}
		$out[ $key ] = is_array( $value ) ? self::strip_protected( $value, $depth + 1 ) : $value;
	}
	return $out;
}

(from Matcher::strip_protected)

Three details make this robust:

  • Keys are normalised before comparison. Gender, date-of-birth, Date_Of_Birth and dateOfBirth all become gender or dateofbirth, so spelling variants can't slip through. The list has 49 normalised keys, from name and photo to maritalstatus, citizenship, religion, sexualorientation, union, passport and visa.
  • It recurses. A religion key inside an experience entry is removed just like one at the top level.
  • One collision is handled explicitly. title is protected at the top level, where it means an honorific (Mr, Ms, Dr), but allowed inside experience entries, where it means a job title. That is the NESTED_ALLOWED list.

The key list is filterable through dev3jb_protected_attributes, so a site can add attributes its local law protects. The profile sanitiser that runs when a candidate saves their profile is stricter still: it keeps only known profile fields and drops every other key.

Layer 3: Build the payload from an allow-list

Removing bad keys is a deny-list, and deny-lists leak: free text can still say "I am a 52-year-old mother". So Matcher::anonymise() doesn't pass the profile through. It builds a new payload from named fields only (headline, summary, location, workplace preference, job types, skills, experience entries, education entries and total years) and drops everything else, including contact details and links.

Free text gets two more treatments:

$scrub = static function ( $text ) use ( $names ) {
	$text = Privacy::mask( (string) $text );
	foreach ( $names as $n ) {
		$text = str_ireplace( $n, '[candidate]', $text );
	}
	return $text;
};

(condensed from Matcher::anonymise)

  • Privacy::mask() replaces emails, phone-like numbers (9 to 15 digits, but not ISO dates), card numbers and IP addresses with placeholders.
  • The candidate's name, from the application and the profile, and each part of it of three letters or more, is replaced with "[candidate]". "Alex builds apps" becomes "[candidate] builds apps".

The result goes through strip_protected() once more, after the dev3jb_match_payload audit filter, so even a filter that adds fields cannot add protected ones. The same anonymised payload is used for embeddings, so the vectors behind recommendations are built without names either.

Illustration of personal details held back before matching Illustration: personal details stay behind; only job-relevant data reaches the matcher.

Layer 4: Rules anchor the score, the model explains it

With a clean payload, scoring runs in two stages.

A transparent rule score

rule_score() needs no AI and is fully reproducible. It scores four criteria from 0 to 100:

  • Skills (40%): the share of the job's skills found in the profile's skills, or named in the experience text. "Wrote TypeScript every day" counts as TypeScript.
  • Experience (25%): total years against the years the job asks for, from its experience level (senior means 5) or a "5+ years" phrase in the description.
  • Role (20%): word overlap between the job title and the candidate's headline and past titles, ignoring words like "senior" and "remote", blended with embedding similarity when vectors exist.
  • Location (15%): 100 for remote jobs; otherwise based on the stated city and workplace preference.

Each criterion also produces a sentence a person can check, such as "Has Accessibility, CSS, React, TypeScript, which the job asks for" or "2.8 years of experience; the job asks for about 5". A test runs the same job and profile twice and asserts identical results.

An AI review that cannot move the score alone

With a key, the Google Gemini API receives the job text, the anonymised candidate text and the rule pre-check (criteria, matched and missing skills, required years). The system prompt repeats the fairness rules and asks for strengths and gaps that cite job requirements. The plugin then blends the answers:

$criteria = array();
foreach ( array_keys( self::WEIGHTS ) as $k ) {
	$criteria[ $k ] = isset( $ai['criteria'][ $k ] ) ? (int) round( ( $rules['criteria'][ $k ] + $clamp( $ai['criteria'][ $k ] ) ) / 2 ) : $rules['criteria'][ $k ];
}
$ai_score = isset( $ai['score'] ) ? $clamp( $ai['score'] ) : $rules['score'];
$score    = (int) round( ( $rules['score'] + $ai_score ) / 2 );

(condensed from Matcher::match)

Every AI number is clamped to 0–100 and averaged with the rule number. A model that answers 100 for a candidate the rules put at 30 produces 65, not 100, and both numbers are stored (rule_score, ai_score) so an employer can see when they disagree. Strengths and gaps are sanitised and capped at four each; if the model returns none, the rule sentences are kept.

What a score is allowed to do

Very little, by design:

  • It never changes a stage. score_application() stores the result and adds a "Match score 72/100 calculated"-style event to the application's timeline. The test asserts the application is still in new afterwards.
  • It never hides anyone. "Best match first" sorts; it does not filter. Unscored and low-scored applications stay on the board.
  • It is visible only where you choose. A setting decides whether employers see scores at all, or only site managers.
  • It can be blind. With blind screening on, employer cards show "Candidate #123" without contact details until the application reaches Interview. A REST test checks that the card JSON contains no part of the name before that stage, and the full name after.

The test that matters most

One PHPUnit test pulls these layers together. It creates a candidate called Priya Raman whose profile summary says "Priya loves React. Email priya@example.com", with a UK mobile number, applies her to a job, mocks a Google Gemini API answer, and scores the application with AI on. Then it serialises the exact request body that was sent and asserts that "Priya", "Raman", the email address and "7700" (from the phone number) appear nowhere in it. It also checks that the final score is the rounded average of the rule score and the mocked 90, and that the stage is still new.

It is one of 65 PHPUnit tests in the plugin, alongside 39 Jest tests. The AI paths, as across this release, were tested with mocked responses; a live-key run is the next step.

The candidate profile filled from a resume A parsed profile: headline, skills, experience and education to check, with a note that personal characteristics are never needed.

Trade-offs

  • Indirect signals remain. A university name or a gap in employment can correlate with protected characteristics. The plugin removes direct attributes and names; it cannot remove every proxy, which is why scores stay decision support.
  • Name scrubbing is literal. Nicknames or names written differently in the summary may survive. Parts shorter than three letters are not replaced, to avoid mangling words.
  • Rules are simple on purpose. Word overlap on titles misses transferable experience; the gap sentence says "check transferable experience" rather than pretending to know.
  • Averaging limits the model in both directions. A genuinely insightful AI review can't lift a score by itself either. We chose predictability over cleverness.

Conclusion

Fair matching isn't a sentence in a prompt. It is a pipeline where the parser has nowhere to put protected data, the profile is stripped by normalised key, the payload is rebuilt from an allow-list with names and contact details masked, and the model's opinion is averaged with a rule score a person can read. Then the score is kept away from every decision. DEV3 AI Job Board & Resume Matcher ships in the DEV3 AI Directory, Booking & Events Plugins Bundle on the DEV3 marketplace, alongside 7 other directory, booking and events plugins, and is being prepared for CodeCanyon. The case study covers the rest of the job board.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles