Skip to content

ADR-0024: Remove G5.3 and G7.4 documentary +5 sub-rule rows

Status: Accepted (2026-05-23) Supersedes:Superseded by:


Context

G5 (Anti-corruption & financial crime record) and G7 (Governance controversies 24m lookback) are deduction rules with base_score=5, floor=0, ceiling=5. Each has a sub-rule row with points=+5 and source_id=NULL:

rule_id sub_rule_id points source_id description
G5 3 +5 NULL "Base score 5 — awarded when no deductions triggered"
G7 4 +5 NULL "Base score 5 — awarded when no deductions triggered"

These rows were seeded in migration 002 to document the deduction model's "clean state" (start at 5, deduct per violation). Migration 008 (008_scoring_columns.sql, lines 14–16) added an explicit note when it introduced base_score:

"Note on G5/G7: sub_rule rows with source_id=NULL (points=5) are documentary. No scraper produces signals for them. base_score=5 + ceiling=5 ensures raw_score stays in [0,5] even if a null-source signal were manually inserted."

The scoring engine (sub_criterion.js) implements the clean state as base_score + pointsSum: with base_score=5 and no deductions triggered (pointsSum=0 → Case B), the engine OMITs the row entirely (OMIT_RAW_SCORE=2.5 → 50 neutral). The +5 documentary rows are never reached: source_id=NULL means no scraper produces signals for them, so they contribute nothing to pointsSum.

Problem

A diagnostic session (2026-05-23) identified a latent masking defect: if a signal were ever manually inserted for G5.sub_rule_id=3 or G7.sub_rule_id=4 with boolean_value=1, the +5 would be added to pointsSum alongside any violation deductions. Because rawScore = clamp(base_score + pointsSum, floor, ceiling) and ceiling=5, the ceiling clamp absorbs the +5 and prevents violations from reducing rawScore below 5 — producing scoreValue=50 (neutral) regardless of how many violations fired.

Arithmetic for G5 (base_score=5, ceiling=5):

Signals that fire pointsSum rawScore scoreValue Verdict
G5.3 alone (+5) +5 clamp(10,0,5)=5 max(0,100−50)=50 neutral — same as clean ✓
G5.3 (+5) + G5.2 one violation (−2) +3 clamp(8,0,5)=5 50 neutral despite violation
G5.3 (+5) + G5.1 (−1) + G5.2 (−2) +2 clamp(7,0,5)=5 50 neutral despite two violations

The same arithmetic applies to G7.4. The masking is complete: no number of G5/G7 violations can score below neutral if the +5 row fires alongside them.

The defect is latent, not live: - Zero signals have ever been inserted for G5.sub_rule_id=3 or G7.sub_rule_id=4 (confirmed against live DB across all runs). - All 38 FI institutions in run_id=41 score 2.5/50.0 on G5 (Case A: no live signals) and G7 (Case B: live signals, all boolean_value=0, pointsSum=0).

Decision

Option A: delete the +5 documentary rows via migration.

The clean-state they documented is already handled correctly by ADR-0023's Case B logic (pointsSum=0 → OMIT_RAW_SCORE). The rows serve no functional purpose. Deleting them at source is the correct fix: it removes the latent hazard permanently and eliminates the documentation confusion about what the +5 "does."

Migration 052 (052_remove_g5_g7_documentary_sub_rules.sql) deletes: - rule WHERE rule_id = 'G5' AND sub_rule_id = 3 - rule WHERE rule_id = 'G7' AND sub_rule_id = 4

Alternative considered — Option B: engine guard

Add a guard in sub_criterion.js to skip any sub-rule with source_id=NULL when accumulating pointsSum. Closes the manual-insert hazard in code while preserving the documentary DB rows.

Rejected: the rows are stale documentation of a pre-neutral-prior intent. Keeping them and adding a workaround leaves the next reader confused about why null-source sub-rules with points>0 exist but are explicitly ignored. Option A removes both the hazard and the confusion.

Pre-flight checks

All confirmed safe before migration:

  1. liveRuleCount (methodology.js): COUNT(DISTINCT rule_id) — deleting sub-rule rows leaves G5 and G7 rule_ids intact. No change.
  2. totalRules (methodology.js): hardcoded JS array of rule_ids. No DB dependency.
  3. MIN(sub_rule_id) join (institution.js): G5 MIN remains 1, G7 MIN remains 1.
  4. sub_criterion.js pointsSum loop: reads SELECT sub_rule_id, points FROM rule WHERE rule_id = ?. After deletion G5 returns 2 rows (sub_rules 1, 2), G7 returns 3 rows (1, 2, 3). Neither deleted row ever contributed to pointsSum (no signals existed for them).
  5. FK safety: zero rows in signal reference (G5, 3) or (G7, 4) — confirmed with direct query across all runs.
  6. Other migrations: no migration other than 008 references G5.sub_rule_id=3 or G7.sub_rule_id=4. Migration 042 references E7.sub_rule_id=4 (unrelated).

Consequences

Score changes

None to headline scores. All G5 and G7 score_sub_criterion rows remain at raw_score=2.5, score_value=50.0 (OMIT sentinel). Stage 1 ESG scores are byte-identical — stage1 hash unchanged (baseline: 43a13b7ac8e8a2e48035a6bcc33c6230, confirmed post-rescore).

Confidence correction for G7

A beneficial side-effect: G7 score_sub_criterion.confidence changes from 0.75 to 1.00 for institutions where GOOGLE-NEWS-RSS scraped all three live sub-rules (G7.1–G7.3) successfully.

Before: subCriterionConfidence(4, 3, [1.0, 1.0, 1.0]) = (3/4) × 1.0 = 0.750 After: subCriterionConfidence(3, 3, [1.0, 1.0, 1.0]) = (3/3) × 1.0 = 1.000

The documentary G7.4 row was being counted as a "missing" signal in the coverage ratio, reducing apparent G7 coverage to 75% even when all three live sub-rules were scraped successfully. After deletion, G7 correctly reports 100% coverage for institutions where GOOGLE-NEWS-RSS ran cleanly.

This confidence change has no effect on pillar or stage1 scores: G7 rows with raw_score=2.5 (OMIT) are filtered from pillar aggregation by pillar.js.

signals_applicable counter

  • G5: signals_applicable 3 → 2 (G5.1 source=NULL, G5.2 GOOGLE-NEWS-RSS — two live sub-rules)
  • G7: signals_applicable 4 → 3 (G7.1–G7.3 GOOGLE-NEWS-RSS — three live sub-rules, matches signals_with_evidence=3 for institutions where Google News scraped successfully)

Both changes are corrections: the counters now reflect the number of sub-rules that a scraper could actually populate, rather than including the documentary null-source row.

Run_id=41 live impact

Zero. Confirmed by querying all 38 FI institutions before and after: - All 38 score 2.5/50.0 on G5 (unchanged — Case A, no live signals for any G5 sub-rule) - All 38 score 2.5/50.0 on G7 (unchanged — Case B, G7.1–G7.3 all boolean_value=0) - Stage 1 hash byte-identical pre- and post-rescore

References

  • Migration 008 — introduced base_score and documented the +5 rows as documentary
  • Migration 052 — this deletion
  • ADR-0022 — neutral-prior A(ii): positive-rule scraped-negative OMIT
  • ADR-0023 — neutral-prior A(iii): deduction rule baseline re-anchored to 50
  • Diagnostic session 2026-05-23 (#esg-screening) — G5/G7 +5 masking arithmetic identified