Custom rules

  1. A signal
  2. Example: a high-risk-jurisdiction rule
  3. Composing with the defaults
  4. Reading check data in a rule
  5. Rules must be pure

A RiskRule inspects the hard facts and emits zero or more RiskSignals. You add rules to RuleBasedRiskStrategy without subclassing it.

interface RiskRule
{
    /** @return iterable<RiskSignal> */
    public function evaluate(RiskContext $context): iterable;
}

RiskContext gives you both the counterparty and the full report.

A signal

new RiskSignal(
    code: 'geo.high_risk',    // stable machine id, shown in the summary
    weight: 0.6,              // 0.0-1.0; clamped if out of range
    adverse: false,          // true = hard negative finding -> forces review
    evidence: Evidence::ungrounded('Registered in a high-risk jurisdiction.', 0.7),
);

Example: a high-risk-jurisdiction rule

use Gawrys\Counterparty\Risk\{RiskRule, RiskContext, RiskSignal, Evidence};

final class HighRiskCountryRule implements RiskRule
{
    /** @param list<string> $countries */
    public function __construct(private array $countries) {}

    public function evaluate(RiskContext $context): iterable
    {
        if (in_array($context->counterparty->country, $this->countries, true)) {
            // adverse:false on purpose - a high-risk jurisdiction is a contributing risk
            // FACTOR (raises the score via weight), not a hard-negative finding. Set
            // adverse:true only for facts that must force human review on their own
            // (e.g. a sanctions hit).
            yield new RiskSignal('geo.high_risk', 0.6, adverse: false,
                evidence: Evidence::ungrounded('High-risk jurisdiction.', 0.7));
        }
    }
}

Composing with the defaults

withDefaultRules() is just a convenience. To mix your rule with the bundled ones, list them explicitly:

use Gawrys\Counterparty\Risk\RuleBasedRiskStrategy;
use Gawrys\Counterparty\Risk\Rule\{SanctionsHitRule, VatStatusRule, BankAccountMismatchRule, InconclusiveCoverageRule};

$strategy = new RuleBasedRiskStrategy([
    new SanctionsHitRule(),
    new VatStatusRule(),
    new BankAccountMismatchRule(),
    new InconclusiveCoverageRule(),
    new HighRiskCountryRule(['XX', 'YY']),
], reviewThreshold: 0.5);

Reading check data in a rule

Rules find results by source and read the raw payload an adapter produced:

foreach ($context->report->fromSource('pl.white_list') as $r) {
    if (($r->raw['bankAccountAssigned'] ?? null) === false) {
        yield new RiskSignal('bank_account.unassigned', 0.7, adverse: true);
    }
}

Rules must be pure

No I/O, no clock, no randomness - given the same context a rule must return the same signals. This keeps an assessment reproducible and cache-friendly. Do network work in a Check (which becomes a fact in the report); rules only interpret facts.


Counterparty Verification - a due-diligence aid, not a compliance product. MIT licensed.

This site uses Just the Docs, a documentation theme for Jekyll.