Past LL144: a jurisdiction rules table for a screening agent

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.