Every screening agent we build starts with two questions from the owner: what do we have to tell the candidate, and what do we have to keep. Until recently the honest answer for most US staffing firms was "NYC Local Law 144 if you place in the five boroughs, and Illinois HB 3773 from the start of 2026". That list is getting longer. Colorado's AI Act, Texas's TRAIGA and California's civil-rights and CCPA rules on automated decision systems all landed on the calendar within about a year of each other, with different triggers, different notice wording and different retention periods.
You cannot hard-code one notice template and call it done, and you should not ask a coordinator to remember which rule applies to a nurse in Denver versus a developer in Austin. This walkthrough builds the boring piece that solves it: a jurisdiction rules table your agent reads before it scores anyone, and the records it writes afterwards.
Dates and scope in this area move. Treat what follows as a data model, not legal advice; your employment counsel decides what goes in the rows.
Step one: work out which jurisdiction applies
The trigger is rarely "where is the firm". It is some combination of where the role is, where the candidate is, and who the employer of record is. Write the rule down per statute rather than inventing a general one.
- NYC LL144 attaches to an automated employment decision tool used for a job or promotion where the position is located in New York City: bias audit within the last year, a public summary of results, and candidate notice at least ten business days before use.
- Illinois HB 3773 amends the Illinois Human Rights Act. If you use AI in a recruitment or employment decision affecting an Illinois applicant or employee you must notify them, and you may not use zip code as a proxy for a protected class.
- Colorado SB 24-205 covers developers and deployers of systems making consequential decisions, employment among them, with duties around risk management, disclosure and notice of an adverse decision. Its start date has already been pushed once, so keep the effective date in a table rather than in your code.
- Texas TRAIGA is intent-based rather than disparate-impact based and hits government use hardest, but a private deployer that intends to discriminate is squarely in scope.
- California comes at it from two directions: the Civil Rights Department's rules on automated decision systems under FEHA, and the CCPA regulations on automated decisionmaking technology, which add pre-use notice, opt-out and access rights plus risk assessments on their own timetable.
The practical consequence for a staffing firm is that one req can sit under two regimes at once: a remote role posted from a New York office and filled by a candidate in Illinois. Design for the union of the obligations, not the intersection.
Step two: the rules table
One row per jurisdiction, owned by counsel, editable without a deploy.
create table aedt_jurisdiction_rules (
jurisdiction_code text primary key, -- 'US-NYC', 'US-IL', 'US-CO', 'US-TX', 'US-CA'
label text not null,
effective_from date not null,
effective_to date,
trigger_basis text not null, -- 'job_location' | 'candidate_location' | 'either'
notice_required boolean not null,
notice_lead_days int, -- ten business days for NYC
notice_template_key text,
opt_out_required boolean not null default false,
adverse_notice boolean not null default false,
bias_audit_required boolean not null default false,
audit_max_age_days int,
record_retention_days int not null,
citation_url text not null,
reviewed_at date not null,
reviewed_by text not null
);
Two of those columns earn their keep on their own. citation_url and reviewed_by mean that when a client's counsel asks why you send a ten-day notice in Brooklyn and not in Boise, the answer is a row with a name and a date on it rather than a developer's memory.
The effective_from and effective_to pair matters more than it looks. Rules that are signed but not yet in force belong in the table now, dated, so that the day they start nobody has to ship code.
Step three: resolve obligations before you score
Resolution happens before the agent reads a single resume.
select r.*
from aedt_jurisdiction_rules r
where current_date between r.effective_from
and coalesce(r.effective_to, date '9999-12-31')
and (
(r.trigger_basis in ('job_location','either') and r.jurisdiction_code = any($1))
or (r.trigger_basis in ('candidate_location','either') and r.jurisdiction_code = any($2))
);
$1 is the set of codes derived from the req's work location, $2 the set derived from the candidate's location. Derive both from structured fields, never from free text in a job description. If a location will not resolve to a code, that is an exception for a human, not a default to "no obligations".
Then fold the rows into one obligation set for that req and candidate.
def obligations(rows):
return {
"notice_required": any(r["notice_required"] for r in rows),
"notice_lead_days": max([r["notice_lead_days"] or 0 for r in rows] or [0]),
"opt_out_required": any(r["opt_out_required"] for r in rows),
"adverse_notice": any(r["adverse_notice"] for r in rows),
"bias_audit_required": any(r["bias_audit_required"] for r in rows),
"retention_days": max([r["record_retention_days"] for r in rows] or [0]),
"applied": sorted(r["jurisdiction_code"] for r in rows),
}
Strictest wins on every field: longest lead time, longest retention, notice if any row asks for one. That is deliberate. It costs a little extra disclosure and no engineering, and it means a mis-coded location fails toward telling the candidate rather than away from it.
Step four: gate the scoring run
The gate is a dozen lines and it is the point of the exercise.
ob = obligations(rows)
if ob["bias_audit_required"] and audit_age_days(req) > max_audit_age(rows):
raise Blocked("bias audit out of date for %s" % ob["applied"])
if ob["notice_required"] and not notice_sent(candidate, req, lead_days=ob["notice_lead_days"]):
queue_notice(candidate, req, ob)
raise Deferred("notice queued; scoring deferred")
if ob["opt_out_required"] and opted_out(candidate, req):
route_to_human_review(candidate, req)
raise Deferred("candidate opted out of automated scoring")
Blocked means nothing is scored and a named human is told. Deferred means the candidate is not stranded: they go into the human queue and a recruiter reads the resume the old-fashioned way. An agent that scores anyway and lets the notice catch up afterwards is the one that turns a paperwork problem into a liability.
Step five: stamp the decision record
Every score the agent writes carries the obligations that were in force when it ran.
alter table screening_decisions
add column jurisdictions_applied text[] not null default '{}',
add column rules_snapshot jsonb not null default '{}'::jsonb,
add column notice_sent_at timestamptz,
add column opt_out_at timestamptz,
add column purge_after date;
rules_snapshot is the resolved obligation set copied in, not joined at read time. Rules change; the record of what you did under the rules as they stood must not. purge_after is computed once from the longest retention in that snapshot, which is what makes an automated purge safe to run later.
A useful weekly check, in one query:
select jurisdictions_applied,
count(*) as decisions,
count(*) filter (where notice_sent_at is null) as missing_notice
from screening_decisions
where decided_at >= current_date - 7
group by 1
order by 2 desc;
missing_notice should be zero. If it is not, the gate has a hole, and you would rather find it on a Tuesday.
What this does not do
It does not make a tool compliant. No table does. It gives the firm, which carries the liability and cannot hand it to a vendor, three things: a record of which rules it believed applied and why, evidence that notices went out before scoring, and a purge date someone can defend.
It also does not cover sourcing tools, scheduling bots or the chat widget on your careers page, any of which may be in scope somewhere. Map those separately, with the same table.
If you want an outside pair of hands on the rules table, the gate or the audit exports behind them, our Rubric Screener work is built this way by default. Contact us with your ATS and the states you place in.