A contractor is due on site Monday. The background check comes back Thursday with a six-year-old misdemeanor. Somebody has to decide what that means, and the law cares a great deal about the order in which the next few things happen. In most firms that order lives in a recruiter's head and a thread in a shared inbox, which is why the file is always thin when counsel asks for it eighteen months later.
This is good agent work, with one firm boundary: an agent can run the clock, assemble the packet and write the record. It does not decide. The Fair Credit Reporting Act's adverse action process is a sequence of notices and waiting periods, and sequences are exactly what software is for. Judgement about a person's record is not.
We build the state machine, the two tables under it, and the daily exceptions list. Keep your consumer reporting agency's documentation and your employment counsel's playbook next to this; the shape below is general, the thresholds in your jurisdictions are not.
What the law actually asks for
Strip the FCRA down to the part a workflow touches. When a firm intends to take adverse action — rescind an offer, pull someone off an assignment, decline a placement — based in whole or in part on a consumer report, there are two notices, in this order:
- Pre-adverse action notice, sent before the decision is final, with a copy of the report and the CFPB's A Summary of Your Rights Under the Fair Credit Reporting Act.
- A waiting period, so the person can see the report and dispute what is wrong in it.
- Adverse action notice, if the decision stands, naming the consumer reporting agency, stating that the agency did not make the decision, and setting out the right to a free file copy and to dispute.
The FCRA itself says "before" without putting a number on the gap. Five business days is the convention most employment counsel land on, and some jurisdictions set their own floor — New York City's Fair Chance Act gives the applicant at least five business days to respond to a written analysis, and California's regulations run on a similar clock. The FTC's FCRA business guidance is the plain-language starting point.
Two things follow for the build. The waiting period is a configuration value per jurisdiction, not a constant in the code. And "business days" means you need a holiday calendar, or your agent will send an adverse action notice on the Friday after Thanksgiving and shorten someone's window by two days.
The state machine
Eight states, one of which is a stop.
report_received
-> clear (no adverse information; close)
-> review_required
review_required
-> no_action (human: record does not affect this role)
-> pre_adverse_prepared (human: proceed to pre-adverse)
pre_adverse_prepared
-> pre_adverse_sent (human approves the packet; agent sends)
pre_adverse_sent
-> dispute_opened (candidate responds; clock pauses)
-> waiting_period_elapsed (timer)
dispute_opened
-> review_required (corrected report or new information)
waiting_period_elapsed
-> no_action (human)
-> adverse_action_sent (human approves; agent sends)
The agent owns the arrows driven by time and by inbound documents. Every arrow into a sent state needs a named human to have clicked approve. That is not decoration: the adverse action notice has to say the consumer reporting agency did not make the decision, and a firm that cannot name who did has a thin story.
Note what review_required does not contain: scoring. No model grades the severity of a conviction, no rubric converts a record into a number. The agent's job in that state is to put the report, the role, the jurisdiction and the assessment form in front of a person.
Table one: the case
create table adverse_action_cases (
case_id bigserial primary key,
candidate_id bigint not null,
placement_id bigint,
job_id bigint not null,
cra_name text not null,
report_reference text not null,
report_received_at timestamptz not null,
work_jurisdiction text not null, -- drives the waiting period
state text not null,
pre_adverse_sent_at timestamptz,
waiting_days int, -- resolved at send time, stored
respond_by timestamptz, -- computed, business days
decision_by text, -- the human, on final states
decided_at timestamptz,
outcome text, -- no_action | adverse
unique (candidate_id, report_reference)
);
Store waiting_days and respond_by as values, not as something you recompute on read. If the firm changes its standard window next quarter, every open case keeps the window it was promised, and the file still explains itself.
work_jurisdiction is the site where the person will actually work, which in staffing is often not where they live and not where your office is. Resolve it from the job, not the candidate.
Table two: the events
create table adverse_action_events (
event_id bigserial primary key,
case_id bigint not null references adverse_action_cases(case_id),
occurred_at timestamptz not null default now(),
event_type text not null, -- state_change | notice_sent | document_attached
-- | candidate_response | assessment_recorded
actor text not null, -- 'agent' or a user id, never blank
from_state text,
to_state text,
channel text, -- email | postal | portal
document_sha256 text, -- the exact PDF that went out
note text
);
The hash matters more than it looks. "We sent the summary of rights" is an assertion; "we sent this 94 KB file, here is its hash, here is the copy in object storage" is a record. Same discipline as a screening agent logging the rubric version it scored against — if you cannot reproduce the artifact, you do not have evidence of it.
Computing respond_by
Business days, a holiday table, and the firm's floor versus the jurisdiction's floor:
create or replace function add_business_days(start_ts timestamptz, n int, jurisdiction text)
returns timestamptz language plpgsql as $$
declare d date := (start_ts at time zone 'UTC')::date; left_n int := n;
begin
while left_n > 0 loop
d := d + 1;
if extract(isodow from d) < 6
and not exists (select 1 from holidays h
where h.holiday_date = d and h.jurisdiction = jurisdiction) then
left_n := left_n - 1;
end if;
end loop;
return (d + time '23:59')::timestamptz;
end $$;
Resolve the number of days from a rules table keyed on jurisdiction, with a firm-wide default, and take the larger of the two. Jurisdiction rules belong in data for the same reason screening rules do: the list grows every legislative session and nobody wants to ship code for it.
select greatest(coalesce(j.min_business_days, 0), f.default_business_days)
from firm_policy f
left join jurisdiction_rules j on j.jurisdiction = $1;
The individualized assessment, as a form
Where a jurisdiction requires an individualized assessment, the agent's contribution is to render the form with the facts already filled in — the offence and date as the report states them, the role, the duties — and leave the reasoning blank for a human to write.
Store it as structured text, not a free-form note in the ATS:
{
"case_id": 2291,
"assessed_by": "u_114",
"assessed_at": "2026-10-02T15:04:00Z",
"role_duties_considered": "Unsupervised access to patient floors, night shift",
"time_since_offence_months": 74,
"relevance_rationale": "Offence is property-related, no patient contact element...",
"mitigating_information_received": true,
"conclusion": "proceed_no_action"
}
One field per thing the rule asks you to consider. When someone reconstructs this case later, they read a form, not a paragraph that may or may not mention the factors.
The daily exceptions list
The agent's visible output is one list, every morning, to the named owner:
- Cases in
review_requiredfor more than one business day. A stalled case is a person waiting by a phone. - Cases where
respond_bypasses today or tomorrow, ready for a decision. - Cases past
respond_bywith no decision recorded for more than three business days. - Pre-adverse packets prepared but not approved.
- Any case where the start date on the placement is earlier than
respond_by— the recruiter is planning around a clock that has not run out.
That last one surfaces the actual commercial conflict rather than hiding it. Someone has to choose between the start date and the process, and the firm is better off making that choice deliberately on Tuesday than discovering it on Sunday night.
What the agent never does
Three hard stops, and they belong in code, not in the runbook:
- It never sends an adverse action notice without an approval event naming a human in the preceding minutes.
- It never shortens
respond_by, including when the candidate replies "no comment". Responses move the case todispute_opened; they do not forfeit the window. - It never writes the reason for a decision. The agent assembles facts; people write conclusions.
Running it in shadow first
Point it at the last ninety days of closed cases, in shadow mode, and compare: for each real case, what state would the agent have computed, what respond_by would it have set, and which notices would it have queued? Two things usually fall out of that first run. Some cases have no record of the pre-adverse notice at all, only an email in someone's sent folder. And the gap between notice and decision, measured properly in business days, is shorter than the firm believed in maybe one case in six.
Neither finding is pleasant. Both are cheaper to find in a report than in a demand letter.
Scope note
This is a one-to-two-week build on top of an existing consumer reporting agency integration, and about half of it is the holiday calendar and the jurisdiction table. It touches no revenue directly, so it is not the first agent we would recommend for a firm that has never automated anything — start where the payback is countable. For a firm placing healthcare or light industrial contractors at volume, where background checks are routine and the file is thin, it moves up the list quickly.
We are engineers, not your lawyers. The thresholds, the notice templates and the question of which jurisdictions apply to a given placement come from your employment counsel; we build the clock that runs them and the record that proves it ran. If you want to talk through where this sits against the rest of your back office, a workflow assessment is the usual starting point.