I’m building Rivale.io, a competitive-intelligence tool for WordPress plugin founders and developers, and one morning my own weekly digest email greeted me with a plugin name rendered as raw code: “Jetpack – WP Security…”. The WordPress.org API sends plugin names HTML-entity-encoded and one field in my notification pipeline was reaching the screen without ever being decoded.
The first fix looked textbook: decode in the one shared function that formats names, covering about 35 call sites in a single edit. Every test passed. Two review passes found nothing. Then I asked one question:
Are all other notification channels affected too?
That surfaced the finding this guide is all about: three code paths used the fixed function only as a fallback, so wherever a stored title existed, the fix never executed. There was no error and no failing test. The repair was just silently skipped.
I ran the investigation from there, choosing each next check and settling every open question against production data, including the moment two audit agents contradicted each other and my live query proved both partly wrong. Claude wrote and ran the code.
This guide walks you through the whole arc: the encoding trap, the fix, the bypass class that survives a green test suite, and the checks that would have caught each one in minutes.
The symptom
A product email arrives with the plugin name rendered like this:
Jetpack – WP Security, Backup, Speed, & Growth
Two separate things are wrong in that one line:
–is an HTML entity (an en dash) showing as literal text.&is a double-encoded ampersand. The name was stored as&, then an escaping step encoded the&again.

If you build anything on top of the WordPress.org plugin API, you will hit this. That API hands back display-ready HTML, not plain text. The author field is a full <a href> anchor tag, and names arrive entity-encoded (–, &, ’).
What makes it easy to miss is that the encoding is not consistent. Across the 100 most popular plugins, 38 names come back entity-encoded and 8 carry the literal character instead, because WP.org passes through whatever each author typed into their readme header.
Architecture at a glance

The most commonly misconfigured component is the dotted path: a writer persisted raw names into a table whose title column is read back in preference to the helper’s decoded output. That path is why fixing the helper was not enough.
Step 1: Find where the encoding enters, and resist the urge to fix it there
The names were stored encoded, exactly as the WP.org API delivered them. First instinct: decode before storing. Wrong, for two reasons that came out of this session:
- Many independent readers already decode at display time. Changing what’s stored changes the contract for all of them.
- Historical rows in immutable logs can’t be backfilled anyway. Decoding at write time only helps future rows.
So the rule we operated under: the database holds the raw value, decoding is a presentation concern. (Step 4 covers the one exception that bit us.)
Step 2: Fix the shared helper, and catch the silent string-op failures
One shared helper shortens plugin names for display, and roughly 35 call sites use it. Adding the decode there looks like total coverage in one edit:
// simplified: the pattern, not the literal file
function shortName(raw: string): string {
const name = decodeHtmlEntities(raw); // decode FIRST
return name.split(/\s+[–—-]\s+/)[0]; // then split/truncate
}
Order matters more than it looks:
Warning: String operations on encoded text fail silently. Our truncation split on a literal
–, which never matches–, so truncation had never worked for encoded names. The full SEO tail survived into headlines and email subjects. Separately, a 70-character subject cut was spending 7 of its characters on markup and could slice an entity in half. No error is thrown in either case. The output is just quietly wrong.
Also check the escape order at render sites. Escaping before decoding is what produces the double-encoded &#8211;:
stored: Jetpack – WP Security
escape: Jetpack &#8211; WP Security ← what the email showed
decode→escape: Jetpack – WP Security ← correct
At this point the fix passed every check: full test suite green, both type-checkers clean, lint clean, two reviewer passes with no defects. It looked done.
Step 3: Ask “is anything else affected?”
The fix so far covered one email. The question “are all other notification channels affected by this issue as well?” triggered a sweep of every notification producer in the codebase, and that sweep found the finding this guide exists for.
Step 4: The || fallback bypass
Three separate sites had this shape:
// simplified
const headline = record.title || fallbackTemplate(shortName(competitor.name));
shortName is fixed. But it’s the right-hand operand of a ||. Wherever record.title is non-empty, the fixed code never executes. And that title was being written by another function from the same raw, encoded name, so the bypass did more than skip the fix. It delivered the exact bug the fix was for, through a path the fix never touched.
Important: Call-site coverage is not execution-path coverage. A source-level fix in a shared helper covers every place the helper is called, not every path a value takes to the screen. After fixing any shared helper, grep for expressions where your helper is the fallback operand:
grep -rn "|| .*shortName(" src/ functions/ grep -rn "|| .*decodeHtmlEntities(" src/ functions/Every hit is a site where your fix silently doesn’t run. This takes seconds and it found three bypasses here.
The sweep also surfaced the exception to Step 1’s rule:
Persisted text that becomes someone else’s input is not a display site. The writer storing raw names into that title column had to decode at write time, because three readers treat that column as primary text and prefer it over any decoded fallback. “Decode only at display” holds for a name in its own column with many independent readers. It breaks the moment a derived string is stored and read back as authoritative.
One more variant from the same sweep, visible only if you render it: a UI component showed a decoded plugin name on one line and the raw encoded title on the next, from the same row.
Step 5: Settle disagreements with live data, not with either claim
Two sweep agents (Claude subagents, each auditing the notification producers independently) contradicted each other on whether the bypassed path ever actually carries a plugin name. One said yes, one traced the writer and said never.
A query against production settled it, using an entity-detection regex worth keeping:
SELECT count(*) AS encoded_rows, min(left(text_col, 90)) AS sample
FROM your_table -- any table holding user-facing text
WHERE text_col ~ '&(#[0-9]{2,5}|amp|lt|gt|quot|apos);';
Both agents were partly wrong. The rows existed (one agent right about the risk), but most were legacy rows from retired code, and the live-path subset was small but real: encoded titles from exactly the bypassed read path. When two automated audits disagree, neither claim is the tiebreaker. The live data is what settled it here.
Live damage count, same regex: about a third of competitor names and a fifth of stored headlines were encoded. This had been minting bad rows for months, including the week of the fix.
Step 6: Decide the backfill question with the read window, not with instinct
No backfill shipped, and that was the deliberate call, not an omission:
- The email digest only reads recent rows (an 8-day window). Old encoded rows never re-enter an email on their own.
- The historical notification log is immutable by design. A backfill couldn’t clean it anyway.
So for everything already stored, the display-time decode is the only fix that applies. Source decode stops new bad rows, display decode renders the old ones correctly, and the stored data stays untouched.
Step 7: Verify the deploy, and state what you can’t verify yet
Post-deploy checks confirmed no function failures and no new encoded rows. But every logged run predated the redeploy, so the honest status at end of session was suggestive rather than proof. The new code simply hadn’t executed on a schedule yet. Real confirmation belonged to the next day’s scheduled runs.
Worth stating plainly in your own write-ups: “deploy succeeded” and “the fix has executed in production” are different claims, and the second one has a timestamp you can check.
Verification checklist
| Check | Action | Expected |
|---|---|---|
| Encoded rows in any text column | Run the entity regex from Step 5 against each table | Known-legacy counts only, no growth |
| New rows post-deploy | Same regex filtered to rows created after the deploy timestamp | Zero encoded |
| Fallback bypasses | grep -rn "|| .*yourHelper(" across the codebase | Zero hits, or each hit decodes its primary |
| Double-encoding | Search rendered output for &# | Absent |
| Truncation actually truncates | Feed an encoded name through the helper in a test | Short name, not the SEO tail |
| Empty-input behavior | decode('') still hits your || 'default' fallbacks | Defaults preserved |
Troubleshooting reference
| Symptom | Root cause | Fix |
|---|---|---|
– or ’ as literal text in email/UI | Value stored entity-encoded (WP.org API), rendered without decode | Decode at every display site |
&#8211; (double-encoded) | Escape ran before decode | Decode first, then escape |
| Name truncation/splitting silently not applied | Split pattern is a literal character that never matches its encoded form | Decode before any split, slice, or length check |
| Fix verified, bug still appears on some surfaces | Fixed helper sits as the right operand of a || fallback | Grep for || yourHelper(, decode the primary operand |
| Old rows still render encoded after the source fix | Stored data unchanged (by design) | Display-time decode, not a backfill, when read windows and immutable logs make backfill useless |
Conclusion
Decoding now happens at the source helper, at every display surface, and at the one write path whose output is read back as primary text.
What I’d carry to any codebase: after fixing a shared helper, grep for the || helper( bypasses, because call-site coverage says nothing about execution paths. And when two audits disagree, the live data is the tiebreaker.
Raw draft by Claude from session transcripts. Edited, fact-checked, and finalized by Matteo Duò.