The goroutine leak was a missing cancel
Memory climbing slowly over days, nothing obvious in the heap profile. It was in the goroutine profile instead: thousands of them parked on a channel receive that would never fire.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel() // this line was the whole bug
WithTimeout allocates a timer and registers the child with its parent. Without cancel(), neither is released until the timeout fires — and if the parent outlives the process, effectively never. go vet catches the obvious cases; this one was behind a helper function, so it didn’t.