Budget fields have to move together
Beeswax rejects any line item with daily greater than lifetime. The first pacer moved only lifetime. Five campaigns succeeded because their windows had closed. Five still inside the window were rejected. The job still read succeeded.
Published 2026-09-09 · Search job: daily budget cannot exceed total
Daily budget cannot exceed total budget. Beeswax rejects the write with 400 {"non_field_errors":["Daily budget cannot exceed total budget."]}. Both budget fields have to move together.
The first deployed product-side pacer moved only lifetime. It succeeded for exactly the five campaigns whose delivery windows had already closed. A closed window releases the full daily budget, so lifetime >= daily happened to hold. It was rejected for the five campaigns still inside or ahead of their window. Five of ten campaigns paced. background_job_health still read succeeded because per-campaign errors are counted rather than thrown. Nothing said failed. The only visible symptom was which five had a null paced_lifetime_budget_cents.
Why the combination only appears sometimes
The violation is not a constant. It appears when a still-open window wants a small lifetime slice while the daily field still holds the buyer's full daily budget. After the window closes, the pacer wants to release the full day, and the two numbers line up again. Point tests that only covered closed windows, or only covered open windows with a tiny daily, missed it.
The planner now returns targetDailyBudgetCents alongside targetLifetimeCents and clamps daily to at most lifetime. That also matters for a campaign pinned at an explicit buyer cap, where lifetime can legitimately be smaller than one day's release. A test asserts the invariant across a simulated day crossed with several spend positions and window shapes, because the first round of point tests was how the bug shipped.
Success that is not success
A background job that records succeeded while half the fleet was rejected is the same class of silence as a campaign whose status says running after a one-day lifetime cap. Read the per-object write, not the job rollup.
This is also why Two things pacing at once stops delivery and A lifetime cap that silently became one day belong in the same Lab. Lifetime is one Beeswax field used for three meanings: buyer cap, open-ended envelope, and paced slice. Daily is the other field. Moving one without the other either rejects the write or recreates a one-day stop. Keep the meanings in their own product columns. Never coalesce them at the adapter edge.
If you are reading a pacer tick that "succeeded" and half the fleet has a null paced_lifetime_budget_cents, assume the daily-versus-lifetime invariant failed for the open windows. Do not add a second trafficking change that day. Fix the paired write, then judge. The 400 text is the tell: daily cannot exceed total.