# 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.

```dsail
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:

```json
{"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, and a `FALSE` carries the
solver's counterexample:

```text
FALSE      a1_notice_within_thirty_days   counterexample: [states_action_taken = True,
 reason_count = 6,
 ecoa_notice_present = True,
 reasons_are_scored_factors = False,
 days_to_notice = 34,
 decision_basis = "scoring",
 reasons_are_specific = False,
 names_creditor = True]
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   counterexample: [...]
FALSE      b2_reasons_are_specific   counterexample: [...]
FALSE      b2_scoring_reasons_are_scored_factors   counterexample: [...]
```

This is `dsail check --summary` output from the hosted service, with the three
repeated counterexamples elided: each is the same full assignment, because the
solver reports the model under which the assertion fails, and here one
dictionary fails four assertions at once. Reading the first: with
`days_to_notice = 34` the thirty-day rule cannot 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](https://github.com/JaxonAI/dsail),
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](policy-to-rules.md), the general authoring sequence.
- [Which rule decided](which-rule-decided.md), the counterexample in a full response.
- [Policy as code for written policies](../compare/policy-as-code-for-written-policies.md), the CI step and what a home-grown engine has to own.
