Designing AI you can defend: explainability and fairness in AuraHire
When an AI scores a person for a job, a number is not enough. Here is how I built AuraHire so every score shows its work, every resume is redacted before scoring, and every decision is replayable.
Most AI recruitment tools score a candidate inside a black box. A number appears, a person is ranked, and nobody can say why. I find that genuinely indefensible — and that discomfort is the whole reason AuraHire exists. It is my thesis system, built around one claim I have to be able to defend in front of an examiner: AI-powered recruitment can be both explainable and fair, or it should not ship.
Three pressures make this non-negotiable when an algorithm scores people for jobs. Legal: hiring is a regulated decision in most jurisdictions, and "the model said so" is not a lawful basis — the EU AI Act classifies recruitment as high-risk, and NYC's Local Law 144 already mandates bias audits for automated hiring tools. Ethical: an opaque score is exactly where discrimination hides — if you can't inspect how a number was produced, you can't tell whether it leaned on a name, an address, a gendered phrase, or genuine skill. Trust: a recruiter who can't see the reasoning won't act on the score, and a candidate who can't see it has no recourse. A number nobody understands is worse than no number at all.
So the constraint I set for the build was blunt: the system is the artifact. No faked AI, no opaque scoring, no demo theatre. If a feature couldn't be defended, it didn't ship. This post walks through how that constraint turned into code — the scoring engine, how every score is made defensible, the guardrails against bias, and the Supabase data model that makes the whole thing safe by construction.
A full-stack AI recruitment platform: a Next.js 16 frontend (UI only, no DB, no AI keys) and a NestJS backend (Fastify) that owns all data, AI, queues, cron, and secrets, in a Turborepo monorepo.
Data lives in Supabase Postgres across 22 tables modeled with Drizzle ORM, with Row-Level Security on every user-data table. AI runs server-side on OpenAI gpt-4o-mini with structured outputs — six engines in total, all returning Zod-validated JSON, never free text.
1. The scoring engine
AuraHire produces two kinds of score. The Profile Score rates a candidate's resume on its own merits — how strong is this resume? The Match Score rates a candidate against a specific job — how well does this person fit this role? Both are composed from weighted components, and both are computed server-side by OpenAI calls that are forbidden from returning free text. Every call uses OpenAI structured outputs derived from a Zod schema, so the model returns a validated JSON document the system can trust rather than prose it has to guess at.
That "no prompt without a JSON schema" rule is the foundation everything else rests on. A score isn't a sentence the model wrote; it's a structured object with components, sub-scores, weights, bands, and evidence — each field validated before it ever touches the database. If the model returns something off-shape, the call fails loudly instead of silently corrupting a candidate's record.
1.1 The eight score dimensions
There are eight transparent dimensions, four per score, each capped by its configured weight so a component can never contribute more than its share. These defaults live in score-thresholds.ts and are mirrored in the active scoring_config row, so an admin can re-tune them without a code change.
| Score | Component | Weight | What it measures |
|---|---|---|---|
| Profile | Completeness | 25 | Section coverage — contact, education, experience, skills, summary, links |
| Profile | Skill Depth | 30 | Number, modernity, and relevance of skills to the desired role |
| Profile | Experience Clarity | 30 | Outcomes, technologies, durations, quantified impact in experience |
| Profile | Education Quality | 15 | Degree match for the desired role plus relevant certifications |
| Match | Skills Match | 40 | Required-skill coverage (synonyms count) plus adjacent skills |
| Match | Experience Match | 35 | Years, seniority, and role/domain alignment vs. the job |
| Match | Education Match | 15 | Degree level and field relevance vs. the requirement |
| Match | Cultural / Language Fit | 10 | Tone and soft-skill alignment between resume and job description |
Each score sums to 100, and a raw number is never shown alone. Every overall score falls into a plain-language band so a figure always travels with a human-readable label: Strong at 70 and above, Partial at 40–69, and Limited below 40. There's one more rule the examiner panel asked for: an application scoring below the configurable auto-reject threshold (default 75 on the Match Score) is rejected the moment scoring completes, and recruiters can't schedule interviews for it — a deliberate, inspectable cutoff rather than a quiet ranking nudge.
Capping each component at its weight is a fairness mechanism, not just bookkeeping. It stops a single dimension — say, a dazzling skills section — from silently dominating a score the way an uncapped, free-form model would. The shape of the score is fixed and visible; only the values inside move.
1.2 Why six engines, not one
It would have been simpler to throw a resume and a job at one giant prompt and ask for a verdict. I didn't, because a single opaque call is exactly the thing the thesis argues against. Instead AuraHire splits the work into six narrow, server-side engines — each with one job, its own versioned prompt, and its own validated output. Separation is what makes the pipeline auditable: redaction provably runs before scoring, and a bias check is its own step with its own record rather than a side effect buried in a scoring prompt.
- Resume parsing — turns an uploaded PDF or DOCX into structured fields (contact, education, experience, skills, certifications) with a
parse_confidencerating. Low confidence falls back to manual entry — never a wall. - PII redaction (hybrid) — a rule-based pass nulls name, email, phone, and social links; an LLM-assisted pass scrubs residual identifiers from free-text fields. Runs before any scoring engine sees the resume.
- Profile Score — the four-component resume-strength engine, with evidence and improvement suggestions.
- Match Score — the four-component candidate-vs-job engine, with evidence, a synthesis paragraph, and optional red/green flags.
- Bias detection — scans a job description for exclusionary language across four categories plus admin custom terms, before publish.
- Aggregate fairness monitor — SQL-only, no LLM cost: surfaces flag counts, top flagged terms, override rate, and score distribution to admins.
Each engine carries a version literal (the scoring prompts are at v1.4.0 and v1.3.0, bias detection at v1.2.0) and every score row records the exact version that produced it. Bumping a prompt is treated as a thesis-defensible event, not a casual edit — because a historical score has to stay reconstructable from the version that made it.
2. Making every score defensible
The governing rule is "no score without evidence." A numeric component never renders as a naked figure. It renders with its score, its max, its weight, a plain-language explanation, and one to three verbatim evidence excerpts pulled straight from the resume — each tagged positive, negative, or neutral, with a signed point contribution that explains how it moved the number.
I made that mechanically honest rather than decorative. The scoring prompts require that the sum of a component's evidence contributions equals the component score — and the engine recomputes the score from the sum and clamps it to the valid range, so any mismatch is overwritten. The explanation can't drift from the math. When a component scores below its max, the prompt forces at least one negative evidence item that names the specific gap (a missing required skill, no quantified outcomes, an education shortfall) rather than padding with generic praise.
There's a second explainability concern most tools ignore: who is allowed to read the evidence, and when. Verbatim resume quotes can leak the very identifiers redaction tries to hide. So every evidence row stores two variants — the full excerpt_text for the candidate's and admin's eyes, and a deterministically scrubbed excerpt_redacted that nulls names, emails, phones, and company tokens. Recruiters reading a breakdown before an interview see only the skills-anchored variant, so they evaluate the work, not the person.
Every profile_scores and match_scores row stores prompt_version, model_used, latency_ms, redacted_fields, and the full raw_output from the model. Match scores additionally store weights_used — a JSONB snapshot of the exact component weights active at scoring time.
Because the weights travel with every match score, a historical hiring decision stays interpretable even after an admin re-tunes the algorithm — you can always replay why a candidate matched a specific job, under the exact configuration that produced it.
That last point is what makes the system examinable end to end. The thesis appendix is literally reconstructed from this audit trail: pick any score, read the prompt version and weights it used, read the evidence it cited, and replay the decision. Explainability isn't a UI flourish bolted on at the end — it's a property of how every score is stored.
3. Designing against bias
My strongest design decision on fairness is that it's enforced upstream, before scoring, rather than measured after the fact. The cleanest way to stop a model from discriminating on a name, a gender marker, or an address is to make sure it never sees them. So before any scoring engine runs, a resume passes through the hybrid PII redaction engine: a rule-based stage nulls contact.full_name, contact.email, contact.phone, and the LinkedIn/portfolio URLs, and an LLM-assisted stage scrubs residual identifiers — names, pronouns, age and gender markers — out of free-text summaries and responsibilities.
Redaction has a subtle trap I had to design around: if you simply delete a field, the model can't tell "absent" from "withheld," and a candidate gets silently penalized on Completeness for the redaction itself. So redacted content is replaced with a [REDACTED] sentinel, and the scoring prompts are written to treat it as a present-but-withheld field — equivalent to a real value being there — never as a gap. The redacted_fields array is persisted on every score row, so an admin can prove redaction happened for any decision after the fact.
On the job side, descriptions are scanned for biased language across four categories — gendered (rockstar, ninja, salesman), age-coded (young, energetic, digital native), ableist (must lift, stand all day, high-energy), and exclusionary ("culture fit" as a gate, available 24/7) — plus admin-defined custom terms. Flags surface inline in the editor, and publishing a job with unresolved flags requires an explicit written override reason that lands in both the bias_flags record and the append-only audit log. A flag moves through flagged → resolved | overridden, and the schema makes the override reason mandatory — you cannot override silently.
Measuring bias only after deployment. Disparate-impact dashboards tell you the harm already happened. AuraHire spends its effort upstream — redact before scoring, flag before publish — so the model has less opportunity to discriminate in the first place.
Collecting demographics to "prove" fairness. Storing race/gender/age to compute disparate impact contradicts the whole redaction philosophy and creates a sensitive dataset that didn't need to exist. AuraHire deliberately collects no demographic labels and surfaces aggregate distributions (flag counts, override rate) instead.
Penalizing redaction. If a model treats a redacted field as missing data, redaction itself becomes a fairness bug. The [REDACTED] sentinel exists precisely so withheld content reads as present, not absent.
Over-flagging the bias checker. A bias detector that flags every adjective trains recruiters to click "override" reflexively, which destroys the signal. The detector is tuned to be conservative — it would rather miss a borderline phrase than cry wolf, because the recruiter sees the description live and can self-edit.
Letting a single component dominate. Uncapped scoring lets one strong (or one biased) signal swamp the rest. Weight caps keep the score's shape fixed and visible.
There's one more guardrail around the act of re-tuning itself. When an admin changes weights or band thresholds, a Preview Impact pass projects how the change would have moved recent applications against the current production weights — before anything is saved. Re-tuning the algorithm becomes a deliberate, inspectable act with a visible before/after, not a quiet edit that silently reshuffles every candidate's standing.
4. Safe by construction
Explainability and fairness are only as trustworthy as the data layer underneath them. If a recruiter at company A could read company B's candidates, or a candidate could read another candidate's evidence, the whole defense collapses. So AuraHire treats the database as the last line of defense, not the first. Authorization is layered — frontend middleware, CORS, a Supabase JWT guard, then role and active-company guards in NestJS — and behind all of it, Row-Level Security on every user-data table in Postgres.
The 22 tables span identity, recruitment, candidate data, AI/scoring, notifications, and audit. The sensitive scoring tables — profile_scores, match_scores, evidence_excerpts, bias_flags — all carry RLS so that even if every application-layer check were bypassed, Postgres itself would refuse to hand a row to the wrong user. The policies are written by hand and kept reviewable independently of the schema, because an examiner should be able to read the access rules as plain SQL.
-- Defense in depth: even if every app-layer guard is bypassed,-- Postgres refuses to return a score row to the wrong user.ALTER TABLE public.match_scores ENABLE ROW LEVEL SECURITY; -- A candidate may read only their own match scores.CREATE POLICY "match_scores_candidate_select" ON public.match_scores FOR SELECT USING (auth.uid() = candidate_id); -- A recruiter may read scores only for jobs they own.CREATE POLICY "match_scores_recruiter_select" ON public.match_scores FOR SELECT USING ( EXISTS ( SELECT 1 FROM public.jobs WHERE jobs.id = match_scores.job_id AND jobs.recruiter_id = auth.uid() ) ); -- Admins may read everything (for the fairness monitor + audit).CREATE POLICY "match_scores_admin_all" ON public.match_scores FOR ALL USING ( EXISTS ( SELECT 1 FROM public.profiles WHERE id = auth.uid() AND role = 'admin' ) );The same pattern repeats per table with the right ownership predicate: candidates own their resumes and applications, recruiters reach a candidate's record only through an application to a job they own, and admins see across tenants for oversight. The audit_logs table is the strict case — it has a read policy for admins and deliberately no insert, update, or delete policy from any user role. Writes happen only through the backend service role, and there is no path for application code to ever update or delete an audit row. Append-only by construction, not by convention.
Every consequential mutation — a score, an override, a status change, a suspension, a delete — writes an audit_logs row with actor, action, entity, IP, user agent, and a JSONB details blob.
Because the table physically cannot be mutated through any user-facing path, the trail an examiner replays a decision from is tamper-evident by design. "No mutation without an audit row" is enforced at the layer that's hardest to bypass.
5. If you're building hiring AI
AuraHire is a thesis system, but the constraints that shaped it generalize to any AI that makes a consequential decision about a person. If I had to compress it into rules I'd hand to anyone building in this space:
- Never let a prompt return free text. Use structured outputs validated by a schema. A score should be an object with components, weights, bands, and evidence — not a sentence you have to parse and trust.
- Never show a score without its evidence. Make the explanation mechanically equal to the math — sum the evidence contributions back to the score — so the "why" can't drift from the "what."
- Enforce fairness upstream, not in a dashboard. Redact identifiers before the model sees them, and flag biased job language before publish. A disparate-impact chart only tells you the harm already happened.
- Don't collect demographics to prove fairness. It contradicts redaction and creates a sensitive dataset you didn't need. Surface aggregate signals (flag counts, override rate) instead.
- Treat redaction as a present-but-withheld signal, never as missing data, or redaction itself becomes a scoring bug.
- Store reproducibility on every decision row — prompt version, model, weights snapshot, raw output — so a historical score stays interpretable after you re-tune the algorithm.
- Make the database the last line of defense. Row-Level Security on every user-data table, and an append-only audit log with no user-facing write path. Don't rely on the application layer alone.
- Make re-tuning a deliberate, inspectable act. Preview the impact of a weight change before saving, and require a written reason for every override. Quiet edits to a scoring algorithm are how bias creeps back in.
None of this makes an AI hiring decision perfect — no system can promise that. What it does is make the decision defensible: every score shows its work, every resume is scrubbed before a model judges it, and every consequential action leaves a trail you can replay. For something as consequential as deciding who gets a shot at a job, that's the floor, not the ceiling.
From concept to creation let's make it happen.
I'm available for full-time roles & freelance projects.
I thrive on crafting dynamic web applications, and delivering seamless user experiences.