Skip to content
All posts
5 min read

A CI Dead Man's Switch for Tech Debt: The Allowlist That Can't Rot

Amit Raz

Amit Raz

Founder, RZ AI Labs

I have exactly one piece of tech debt that cannot be forgotten. Not because I remember it, but because CI will not let anyone forget it.

This is my favorite part of the markdown-file system I have been writing about this week, and it is the piece that turns a ticket from a note into a mechanism.

Illustration of a Claude robot holding a green shield next to a CI screen, with a ticket, an allowlist file and a lint script chained together on the desk
The code fails the build, the CI explains why, the ticket documents the story.

The background

The project has a rule: all logging is structured. Event names only, never free-form prose, with the dynamic values in structured fields. One violation of that rule was left in the code. I did not want to fix it in the commit where I found it, because that commit touched CI wiring and the fix was a behavior change somewhere else entirely. So I opened a ticket.

Up to here, a normal story. And up to here, this debt was also on track to quietly rot for a year, like every "we'll get to it" ticket ever written.

Two files, two comparisons

So I attached two files to the ticket: an allowlist with a single line in it, the location of the known violation, and a lint script that runs in CI. The script makes two comparisons, not one.

First comparison: a violation in the code that has no line in the allowlist is a new violation. CI fails. This is the obvious half; nobody can add new debt of this kind.

Second comparison: a line in the allowlist that has no matching violation in the code is an orphaned entry. CI also fails. This is the half that makes it a dead man's switch: you cannot fix the code and leave the marker behind.

The allowlist file says all of this out loud:

# Structured-logging lint allowlist.
#
# Call sites that predate the lint and violate the event-name rule.
# They are reported as ALLOWLISTED and do not fail CI; every NEW
# violation does. A STALE entry (no violation at that location any
# more) also FAILS the lint, so the allowlist cannot silently rot:
# a fixed call site must have its line deleted here.
#
# Do not add to this file to silence a new violation. Fix the call site.

src/platforms/linkedin.py:281

And the heart of the lint script is a few lines:

violations = scan_roots(roots)          # AST scan of every logger call
allowlist = load_allowlist()            # path:line entries

allowlisted = [v for v in violations if v.location in allowlist]
new = [v for v in violations if v.location not in allowlist]
stale = sorted(allowlist - {v.location for v in violations})

for violation in new:
    print(f"ERROR {violation}")
for location in stale:
    print(f"ERROR {location}: stale allowlist entry, no violation "
          f"here any more; delete the line from the allowlist")

return 1 if (new or stale) else 0

One detail worth copying: entries are keyed by path:line, so even editing the lines above a violation invalidates its entry. That is intended pressure to fix the call site rather than move the marker around.

How it plays out

  • Today: the violation exists, the allowlist line exists, they match. CI is green.
  • Tomorrow: someone fixes the log line. The violation disappears, the line becomes orphaned. CI fails; not because the fix is bad, but because the marker stayed behind.
  • The only way back to green is to delete the line from the allowlist. And whoever deletes it walks straight past the ticket on the way.

That last step closes the loop, because the CI workflow itself points there. Two comment lines above the command that fails the build:

- name: Structured-logging lint
  # Event names only, never free-form prose. Pre-existing violations
  # are held in scripts/structured_logging_allowlist.txt; new ones
  # fail here. See docs/tickets/structured-logging-violations.md.
  run: python scripts/lint_structured_logging.py

The code fails the build, the CI explains why, and the ticket documents the story. Nobody has to remember anything.

What does this buy you?

Debt that is green today, but closed from above and closed from below. Closed from above: no new debt of the same kind can enter, because a new violation fails the build. Closed from below: the existing debt cannot be half-closed, because fixing the code without clearing the marker also fails the build. The system does not trust anyone, human or agent, to remember. In a project where agents write most of the code, that property is worth more than any convention document.

Do you have a way to force tech debt not to be forgotten, or does it just quietly rot in your backlog like everywhere else? I asked the same question on X; the original tweet is here.

FAQ

What is a dead man's switch in CI?

A check that fails when an expected signal disappears, not only when a bad one appears. Here the allowlist entry is the signal: as long as the known violation exists, the entry matches it and CI stays green. The moment the violation is fixed, the entry is orphaned and CI fails, forcing the cleanup to be completed rather than half-done.

Why not just a TODO comment or the ticket alone?

Both rely on someone remembering to look. This mechanism is enforced by the build in both directions, and the build is the one thing every contributor, human or agent, must get past. The ticket still holds the full story; the build is what routes people to it.

Does it scale beyond one violation?

Yes. The allowlist is just N location-keyed lines, and the same two-way comparison works for any lintable rule: banned APIs, deprecated imports, missing types. It is a ratchet: the debt count can only go down, and every decrease is permanent.

This wraps the series on running an AI-agent project from plain markdown files: decisions.md, progress.md, and markdown tickets.

Building something with AI?

I help teams ship custom agents, AI strategy, and software that works.