---
title: "One Regex Boundary Held 43 Blog Posts Back"
canonical: https://dxdev.com/blog/markdown-defeats-guardrail-regex/
datePublished: 2026-09-09
---
## Dead for five nights

At 6:58 AM on the sixth day, we found the [nightly blog drafter](/blog/three-failures-one-silence/) had been dead since the previous week. It hadn't crashed loudly. It produced nothing, night after night, while the cron log reported success. When we finally traced it, four separate bugs were stacked on top of each other. Three were things we'd assumed were broken and weren't. The fourth was a single word boundary in a regular expression, and it's the one that cost us time.

Once we cleared the pile, the drafter caught up and wrote 43 posts in one run. The bug worth writing down is the one where the code was technically correct and still wrong.

## A gate that reads its own footers

Every draft in the pipeline passes through a scrub step before anything is written to disk. It's a list of regex rules that catch the company's internal shorthand and swap it for a public-safe phrase. One of those rules is deliberately boring:

```python
Rule("company token", r"(?i)\bacme\b", "prose", "the platform")
```

It's case-insensitive and word-bounded, and it replaces the match with "the platform." It had been running clean for months. Its job is to keep the internal codename for the business out of any published post, and on every draft we'd checked by hand, it worked.

What it missed was the drafter's own source material. The internal work-log entries that feed the drafter end with an attribution footer, and the footer writes the codename wrapped in markdown emphasis, like `_acme_`. That string carries the codename just as plainly as the bare word. It should have tripped the rule. It didn't. The survivor went through scrub and into the held-for-review queue, where the gate flagged it as unresolved and stopped the whole night's batch rather than publish something unscrubbed. Failing closed is the right default. It's also why the drafter looked dead and didn't throw an error we could grep for.

`\bacme\b` doesn't match `_acme_` because Python's regex `\b` treats underscore as a word character, the same as a letter or digit. In `_acme_`, the position between the leading underscore and the "a" isn't a boundary, since both sides are word characters. The rule was running, but it couldn't see the match in the first place.

## The fix that fixed the wrong bug

Our first patch was the obvious one: stop treating underscore as a word character entirely, so it behaves like a space or a comma on either side of a match. We wrote it and ran it against the held drafts. Every `_acme_` survivor disappeared. We took that as a good outcome and let it run.

Two nights later a different problem showed up. Legitimate prose quoting real module paths, like `refresh_acme_status`, started getting flagged as company-token leaks. Under the new definition the codename sat between two boundaries, because underscore no longer glued it to its neighbors. The rule meant to protect one specific word was now firing on the same three letters buried inside a snake_case identifier that had nothing to do with company disclosure. That's the mirror image of the original bug. It cost us a re-review of every draft that had gone out in that two-night window, to make sure nothing was mangled or wrongly held.

Underscore is neither uniformly a word character nor uniformly a separator. It depends on where it sits. At the edge of a word, right before or after whitespace or punctuation, it's doing emphasis. Between two letters, it's doing snake_case. The fix had to tell those apart:

```python
_WORD_CHAR = "[0-9A-Za-z]"
_LEAD = rf"(?<!{_WORD_CHAR})(?<!{_WORD_CHAR}_)(?={_WORD_CHAR})"
_TRAIL = rf"(?<={_WORD_CHAR})(?!{_WORD_CHAR})(?!_{_WORD_CHAR})"
_BOUNDARY = rf"(?:{_TRAIL}|{_LEAD})"
```

`_LEAD` only counts as a boundary if no letter or digit comes before it, and no underscore-then-letter before that. `_TRAIL` mirrors it. Every rule pattern is compiled once with the literal `\b` swapped out for `_BOUNDARY`, so nothing upstream had to change except that one compile site. Underscore-at-the-edge versus underscore-in-the-middle is the entire fix, and the same distinction separates a false negative from a false positive on the same three letters.

## What else is assumed

The scrub rules were never wrong about what to catch. They were wrong about what a word boundary means once the text can carry its own formatting. Plain text was the only input we'd tested against. Anywhere else in the pipeline that reaches for `\b` and calls it done is making the same bet, and we won't know which one is wrong until it holds a batch for five nights and we go looking.

If you run automated publishing or filtering steps, find out what kinds of text they've been tested against, and whether that includes formatted text such as markdown. A filter that fails closed can make a whole pipeline look dead instead of reporting an error, and a cron log that says success is no proof that any work was produced.
