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:
liveRuleCount(methodology.js):COUNT(DISTINCT rule_id)— deleting sub-rule rows leaves G5 and G7 rule_ids intact. No change.totalRules(methodology.js): hardcoded JS array of rule_ids. No DB dependency.MIN(sub_rule_id)join (institution.js): G5 MIN remains 1, G7 MIN remains 1.sub_criterion.jspointsSum loop: readsSELECT 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).- FK safety: zero rows in
signalreference(G5, 3)or(G7, 4)— confirmed with direct query across all runs. - 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_applicable3 → 2 (G5.1 source=NULL, G5.2 GOOGLE-NEWS-RSS — two live sub-rules) - G7:
signals_applicable4 → 3 (G7.1–G7.3 GOOGLE-NEWS-RSS — three live sub-rules, matchessignals_with_evidence=3for 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_scoreand 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