A disk filled up that had no business filling up. logrotate was clearly running, the rotated files were there with their dates, and yet du insisted the log was still huge. The file on disk was tiny and a phantom gigabyte had gone missing.
The giveaway was lsof | grep deleted. The app still held an open file descriptor to the old inode, the one logrotate had renamed out from under it, and kept happily writing to it. The directory entry was gone but the inode stayed alive, and invisible, until the process let go. My logrotate config used copytruncate's sibling pattern with a postrotate that sent SIGHUP, and the app simply ignored the signal.
Two fixes. The proper one is to make the app reopen its log on SIGHUP, which is what the signal is conventionally for. If you can't touch the app, copytruncate in the logrotate stanza copies the file then truncates the original in place, so the descriptor stays valid and the inode never changes. You lose the handful of lines written between copy and truncate, which for most logs is an acceptable price. I went with copytruncate, the disk came back, and I added a note to check lsof first next time.