The disk was filling and logrotate was, by every measure I could find, working perfectly. The rotated files were there, neatly numbered and gzipped, exactly as configured. The active log was a sensible size. And yet df kept creeping up and du on the log directory couldn't account for it.
The culprit is one of those things that's obvious the moment you remember it. logrotate had renamed the file, but the application still had the original file open by its descriptor. Unix doesn't care what you call a file once a process is holding it open. The bytes keep going to the same inode, now unlinked from any name, invisible to du but very much consuming disk. lsof | grep deleted told the whole story in one line.
The proper fix is to make the app reopen its log file, which logrotate triggers with a postrotate hook. Most daemons reopen on SIGHUP. This one ignored SIGHUP entirely, because of course it did. So copytruncate it was:
/var/log/badapp/*.log {
daily
rotate 7
compress
copytruncate
}
copytruncate copies the file then truncates the original in place, so the descriptor the app is clinging to stays valid and just gets reset to zero. There's a tiny race where lines written between copy and truncate can be lost, which I can live with for this particular app's logs.
The deeper lesson, which I keep relearning, is that rotation isn't about the filename. It's about the file descriptor. logrotate had done its job flawlessly. The app simply refused to look up and notice the world had moved.