Most of the writing about AI hiring rules is about bias audits. We have covered that side too: selection rates and impact ratios in SQL and the overview of NYC LL144 and Illinois HB 3773. But the audit is an annual event run by a third party. The part that runs every day, and the part that actually breaks in production, is the candidate-facing paperwork: the notice that goes out before a tool scores anyone, the path for a candidate who does not want to be scored, and the explanation after a decision goes against them.
For a staffing firm this is not a vendor problem. The firm is the employment agency in the transaction, and the obligations sit with the firm regardless of whose software produced the score. So the flow has to be built, and it has to leave records.
Read the primary sources rather than a vendor summary of them: the NYC rules on automated employment decision tools, the Illinois amendment to the Human Rights Act, HB 3773, and the Colorado AI Act, SB 24-205, whose effective date has already been moved once, so check where it stands before you build to it. Nothing below is legal advice; get your employment counsel to sign off on the wording and the timing. The engineering is the part we can help with.
Three obligations, one state machine
Strip the statutes down to what a system has to do and you get three moments:
| Moment | Trigger | What the system owes the candidate |
|---|---|---|
| Notice | Before a tool scores an application | A statement that an automated tool is used, what it assesses, and how to ask for another route |
| Opt-out or accommodation | Candidate asks | A path to a decision made by a person, without silently dropping the application |
| Explanation | Adverse decision influenced by the tool | The reason, in terms the candidate can act on, and a named human contact |
Everything else, retention windows, the audit itself, the public summary, hangs off those. Model them as states on the application, not as fields scattered across the ATS.
create type screening_state as enum (
'received',
'notice_sent',
'notice_period_elapsed',
'scored',
'human_review',
'decided'
);
create table screening_case (
id bigserial primary key,
application_id bigint not null unique,
candidate_id bigint not null,
job_id bigint not null,
jurisdiction text not null, -- resolved at intake, see below
state screening_state not null default 'received',
notice_sent_at timestamptz,
opted_out_at timestamptz,
scored_at timestamptz,
decided_at timestamptz,
decided_by text, -- a person's name, always
updated_at timestamptz not null default now()
);
One row per application, not per candidate. A candidate who applies to three reqs gets three notices and three decisions, and conflating them is how you end up unable to answer "what happened on this req" a year later.
Step one: resolve jurisdiction at intake
The rules attach to where the job is and where the candidate is, not to where your firm is incorporated. Resolve it once, at intake, and store the answer. Do not recompute it at decision time from data that may have changed.
alter table screening_case
add column job_location text,
add column candidate_state text,
add column rules_applied text[]; -- e.g. {'nyc_ll144','il_hb3773'}
A remote req that a firm in Ohio fills with a candidate in Queens is the case everyone forgets. The cheap answer, and the one most of our clients land on after an hour of debate, is to apply the strictest set of rules to every application. One flow, one script, one set of records. The alternative is a matrix of behaviours that nobody can test and a recruiter who has to remember which script applies today.
If you do branch, branch on rules_applied and nowhere else, so the branch is greppable.
Step two: gate the scorer on the notice
The notice is only worth anything if it precedes the scoring. That means the scorer must refuse to run on a case that is not ready, rather than trusting the caller to check.
ELIGIBLE = {"notice_period_elapsed"}
def score_application(case, rubric):
if case.state not in ELIGIBLE:
raise NotYetScorable(
f"case {case.id} is in state {case.state}"
)
if case.opted_out_at is not None:
raise OptedOut(case.id)
result = rubric.score(case)
log_score(case, result) # inputs, per-criterion rationale, model version
return result
Raise, do not return None. A screening agent that silently declines to score is a screening agent that quietly drops candidates, and the drop will not show up until someone asks why a req had forty applicants and eleven scores.
The notice window is a business-day calculation, which is a small pile of edge cases you should write once and test:
from datetime import date, timedelta
def business_days_after(start: date, n: int, holidays: set[date]) -> date:
d, added = start, 0
while added < n:
d += timedelta(days=1)
if d.weekday() < 5 and d not in holidays:
added += 1
return d
Pass the holiday set in from configuration. Hard-coded US holidays inside a function is a bug with a one-year fuse.
Step three: make the opt-out do something
An opt-out link that files the candidate under "no thank you" is worse than no link at all. The opt-out has to route the application to a person, with a deadline, and the queue has to be visible.
create table human_review_queue (
case_id bigint primary key references screening_case(id),
reason text not null, -- 'opt_out' | 'accommodation' | 'escalation'
queued_at timestamptz not null default now(),
assigned_to text,
resolved_at timestamptz,
outcome text
);
The daily exceptions list, the same pattern we use in the Timesheet and Compliance Chaser, is one query:
select q.case_id,
q.reason,
q.queued_at::date as queued,
current_date - q.queued_at::date as days_waiting,
coalesce(q.assigned_to, 'UNASSIGNED') as owner
from human_review_queue q
where q.resolved_at is null
order by q.queued_at;
Put it in the same morning email as the timesheet exceptions. An opt-out sitting unassigned for nine days is the fact you least want to discover during a complaint, and it costs nothing to surface it on day one.
One more thing worth building: a manual review path must not reuse the model's score as an input. If the reviewer opens a screen showing "the agent said 62 out of 100", the review is not an alternative to the tool, it is the tool with a signature underneath. Hide the score on that screen.
Step four: write the explanation at decision time
The worst version of this is reconstructing a rationale months later from logs. Write it when the decision is made, in the same transaction as the decision, and store the text you actually sent.
create table adverse_decision (
case_id bigint primary key references screening_case(id),
decided_at timestamptz not null,
decided_by text not null,
tool_influence text not null, -- 'none' | 'input' | 'primary'
rationale text not null, -- what the person concluded
notice_text text not null, -- verbatim, as sent
sent_at timestamptz
);
tool_influence is the column reviewers ask about and almost nobody has. "Did the tool contribute to this rejection, and how much" is not answerable from a score table alone, because most rejections in staffing happen for reasons that never touch the rubric: the client filled the role, the rate did not work, the candidate stopped replying. Record the distinction at the moment a human knows it.
Keep the rationale in the vocabulary of the req. "Below the rubric threshold on the licensure criterion; the req requires an active state license at start" is a sentence a candidate can act on. "Insufficient composite score" is not, and it is the sentence that turns a rejection into a complaint.
Step five: prove it, on demand
The test of the whole flow is a single question, asked about one application, answered in minutes: what did we tell this person, when, what scored them, who decided, and what did we send back? One join should answer it.
select c.application_id,
c.rules_applied,
c.notice_sent_at,
c.opted_out_at,
s.model_version,
s.criterion_scores,
q.reason as review_reason,
q.assigned_to,
d.decided_by,
d.tool_influence,
d.sent_at
from screening_case c
left join score_log s on s.case_id = c.id
left join human_review_queue q on q.case_id = c.id
left join adverse_decision d on d.case_id = c.id
where c.application_id = $1;
If that query needs a spreadsheet or a Slack search to complete, the flow is not built yet, whatever the vendor brochure says. And run it as a scheduled check, not only on request: any case that reached scored without a notice_sent_at, any adverse decision without notice_text, any queue row older than your own service level. Three counts in the morning email.
What we would not build yet
An automated appeals adjudicator. Firms ask, because appeals are tedious. But an appeal is the one place where a person reading the file is the entire point, and a model that re-decides its own decision is a closed loop with a candidate inside it. Route appeals to a named human, give them the file, and keep the volume low enough that this is affordable, which mostly means scoring fewer things.
Also not yet: candidate-facing chat that explains scores in real time. The explanation has to match what you can defend in a records request, and generated prose drifts. Send the stored notice_text.
The order to build it in
Notice gate first, because it is the one with a clock on it. Then the human review queue, because an opt-out with nowhere to go is a live problem. Then the decision record with tool_influence. The scoring rubric itself, which is the part everyone wants to start with, is the last thing that should go live, and it should go live behind regression tests and a retention and purge job.
We build this flow as part of the Rubric Screener, and we build it before the scoring, not after. If you are running screening on Loxo, Crelate, JobAdder, Vincere or Recruiterflow and cannot answer the step-five query today, tell us your ATS and your monthly application volume and we will tell you what it takes.