Adverse action notices under Regulation B, checked by rules compiled from the written procedure
A worked example on 12 CFR 1002.9. The principal reasons for a credit denial, the 30-day clock and the ECOA notice as rules a program can check, each named for its clause, with a result that reproduces months later against the same hash.
When a lender declines a credit application, Regulation B requires a notice that states the principal reasons, and the reasons have to be the actual ones (12 CFR 1002.9). Most lenders assemble reason codes by hand after the decision. This page takes the written procedure a compliance team already follows for that notice and shows the whole pattern on it: turn a written policy into rules a program can check, each rule named for the clause it enforces, then check every outgoing notice and get the same answer every time.
It teaches the pattern on this regulation. It is not a product for Regulation B, and it is not legal advice: the values are invented, and the procedure your own compliance team has written is the policy you would compile. Regulation B also lets a creditor disclose the applicant's right to request the reasons instead of stating them (1002.9(a)(2)(ii)); this procedure states them, so the rules below assume that route.
The requirement, clause by clause
| Clause | What it requires of the notice |
|---|---|
| 1002.9(a)(1)(i) | Sent within 30 days after the creditor receives a completed application. |
| 1002.9(a)(2) | States the action taken; gives the creditor's name and address. |
| 1002.9(b)(1) | Carries the Equal Credit Opportunity Act notice, naming the federal agency that administers compliance for this creditor. |
| 1002.9(b)(2) | States specific reasons that indicate the principal reasons for the action. A statement that the applicant failed to reach a qualifying score, or did not meet internal standards, is not specific enough. |
| Comment 9(b)(2)-1 | The regulation sets no number, but more than four reasons is unlikely to help the applicant. This procedure caps the list at four. |
| Comments 9(b)(2)-3 and -4 | Where a credit scoring system produced the decision, the reasons are the factors the system actually scored. |
The rules, compiled from the written policy
Each assertion below is one clause. The comment above it names the clause; the rule's name repeats it, so a result that reads b2_no_more_than_four_reasons is FALSE already says which requirement it is about. The service has no field for the clause a rule came from, so the name and the comment are the whole convention, and a reviewer reads each rule beside the clause it claims to enforce.
version 1.3;
// @ask days_to_notice How many days passed between the completed application and the date on the adverse action notice?
// @range days_to_notice 0..3650
declare days_to_notice as numeric;
// @ask states_action_taken Does the notice state the action the creditor took on the application?
declare states_action_taken as boolean;
// @ask names_creditor Does the notice give the creditor's name and address?
declare names_creditor as boolean;
// @ask ecoa_notice_present Does the notice carry the Equal Credit Opportunity Act statement and name the federal agency that administers compliance for this creditor?
declare ecoa_notice_present as boolean;
// @ask reason_count How many principal reasons for the adverse action does the notice list?
// @range reason_count 0..50
declare reason_count as numeric;
// @ask reasons_are_specific Is every listed reason specific to this application, rather than a statement that the applicant failed to reach a qualifying score or did not meet internal standards?
// @context reasons_are_specific "Insufficient income for the amount requested" is specific; "did not meet our credit scoring cutoff" is not.
declare reasons_are_specific as boolean;
// @ask decision_basis Was the decision made by a credit scoring system, by judgmental review, or by both?
declare decision_basis as enum {"scoring","judgmental","mixed"};
// @ask reasons_are_scored_factors Where a scoring system was used, is every listed reason a factor that system actually scored for this applicant?
// @context reasons_are_scored_factors Compare the notice against the decision record; answer "unknown" if the scored factors are not in front of you.
declare reasons_are_scored_factors as boolean;
// 12 CFR 1002.9(a)(1)(i): notice within 30 days after receiving a completed application.
assert a1_notice_within_thirty_days { days_to_notice <= 30 };
// 12 CFR 1002.9(a)(2): the notice states the action taken and names the creditor.
assert a2_states_action_taken { states_action_taken };
assert a2_names_creditor { names_creditor };
// 12 CFR 1002.9(b)(1): the ECOA notice, naming the administering agency.
assert b1_ecoa_notice_present { ecoa_notice_present };
// 12 CFR 1002.9(b)(2): a statement of specific reasons that indicates the principal reasons.
assert b2_at_least_one_reason { reason_count >= 1 };
assert b2_reasons_are_specific { reasons_are_specific };
// Comment 9(b)(2)-1: more than four reasons is unlikely to help the applicant; this procedure caps the list at four.
assert b2_no_more_than_four_reasons { reason_count <= 4 };
// Comments 9(b)(2)-3 and 9(b)(2)-4: where a scoring system decided, the reasons are the factors actually scored.
assert b2_scoring_reasons_are_scored_factors { Implies(decision_basis == "scoring", reasons_are_scored_factors) };
Compiling it returns a ruleset addressed by a content hash and a claim manifest: the eight questions above, each with its declared type, range and context. The hash is what every later step names.
Extraction: your model reads the notice, the service never does
The prompt pack turns the manifest into one question per claim, with the JSON schema the answers must fit. Your model reads the outgoing notice and the decision record and answers each question. Your model extracts and never decides: nothing in the answers is a judgment about whether the notice complies, only what the notice says. Where the decision record is not in front of it, the right answer to were the reasons the factors actually scored is "unknown", and the one rule that needs it will say so rather than guess.
What crosses the wire is the claim dictionary, never the notice and never the application:
{"days_to_notice": 34, "states_action_taken": true, "names_creditor": true,
"ecoa_notice_present": true, "reason_count": 6, "reasons_are_specific": false,
"decision_basis": "scoring", "reasons_are_scored_factors": false}
The check
That dictionary, checked against the hash, comes back with every assertion's own result. Every result names the rule that decided:
FALSE a1_notice_within_thirty_days
TRUE a2_names_creditor
TRUE a2_states_action_taken
TRUE b1_ecoa_notice_present
TRUE b2_at_least_one_reason
FALSE b2_no_more_than_four_reasons
FALSE b2_reasons_are_specific
FALSE b2_scoring_reasons_are_scored_factors
This is dsail check --summary output from the hosted service. The full JSON response also carries each assertion's source text. Reading the first: the rule is days_to_notice <= 30, the dictionary said days_to_notice was 34, so the thirty-day rule does not hold.
There is no overall verdict. Four clauses were violated; the same notice lists six reasons, one of them a bare scoring-cutoff statement, went out on day 34, and lists reasons the scoring system did not score. What that costs, revising the notice before it is sent or routing it to a compliance officer, is the lender's decision, made in the code that reads these results. The SDK example, examples/adverse_action.py in the dsail repository, makes it this way: any FALSE sends the notice back for revision, any UNKNOWN holds it until the missing fact is in front of someone, and only a clean notice is sent.
Months later
An examiner asks why a particular applicant was told what they were told. The lender has the ruleset hash the notice was checked against, the claim dictionary that was submitted and the approval a compliance officer recorded against that exact hash. Re-running the same dictionary against the same hash returns the same bytes: the same answer every time. If the procedure has since changed, the new revision has its own hash and its own approval, and the old one is still there to answer for the old notices.
What this page is not
Not a determination of what Regulation B requires of any lender; the clauses above are read from the regulation and its official commentary for the sake of the example, and the procedure you compile is your compliance team's. Not a product: one worked example, on the regulation agents ask about most, so that the pattern is visible on a real requirement. The same shape fits any written procedure that already says what a compliant document contains.
Where to look next
- Turn a policy document into rules a program can check, the general authoring sequence.
- Which rule decided, the rule that decided in a full response.
- Policy as code for written policies, the CI step and what a home-grown engine has to own.