LeanZero Management: two scheduling engines, one schedule
Mihai Perdum
Author
14 min readAugust 14, 2026
Key takeaways
When one rule has two implementations, don't test them for equality — test that one is a fixed point of the other. It tolerates conventions that are supposed to cancel and fails only when they stop cancelling.
A fixed-point test catches the engines drifting APART. It cannot catch them drifting TOGETHER — that needs a recorded snapshot of the authoritative engine's output. I proved both by mutation.
Write the compensating constant's REASON at the call site. `lag + 1` with no comment is indistinguishable from a bug, and the next person will 'fix' it.
On Forge, the thing the user clicks cannot be the thing that does the work — most functions time out at 25s, while an async event consumer can be raised to a 900s maximum.
When a definition turns out to be wrong, grep for the definition, not for the symptom.
Every project-planning tool has a scheduling engine. If you build one on Atlassian Forge, you will end up with two, and they will not agree.
That is not a design failure, it is the platform. You need a live engine in the browser, because dragging a bar has to reflow the whole downstream plan at 60fps and a round-trip to a resolver for every mousemove is not a product. You also need one on the server, because triggers, scheduled jobs and queue consumers run with no browser attached. So the same rule — when does this task start, given its predecessors? — gets implemented twice, in two languages of convention, by the same person on different days.
This is a tutorial about the bug class that arrangement produces, why it is invisible, and the test harness that pins it down. It is drawn from LeanZero Management, our Microsoft-Project-style portfolio planner for Jira Cloud, which is in review with Atlassian now and not publicly listed yet. Everything below is traceable to code and commits in that repo, and the numbers are from runs I did on this machine today rather than from the commit log — I will be explicit about which is which.
The bug class: nothing goes red
Most bugs announce themselves. A resolver throws, a 400 comes back, a test flips red, something in the UI goes blank. You find them because they make noise.
Two-engine drift makes no noise at all. Both engines run. Both return well-formed dates. Nothing 500s, nothing logs a warning, and every existing test passes, because each engine is individually correct by its own lights. The only symptom is that the preview shows the user a date one day off from the date Apply eventually writes into Jira.
And the user believes the preview. That is the whole problem. They drag a bar, watch forty downstream tasks reflow, see the milestone land on the 12th, click Apply, and Jira says the 15th. There is no error to report. They just quietly stop trusting the tool.
Warning
This bug class scales with how much the user trusts your preview. The better your live preview is, the more expensive a one-day drift becomes — you have spent the whole product budget teaching them to believe a number that is wrong.
Why the two engines disagree on purpose
Here is the specific collision, and it is a good one, because both sides are individually defensible.
The backend advances working days like this — src/services/working-days/calculator.js:
js
1exportfunctionaddWorkingDays(startDate, daysToAdd, workingDays =DEFAULT_WORKING_DAYS, bankHolidays =newSet()){2if(!startDate || daysToAdd <=0)return startDate ?newDate(startDate.getTime()):null;34const d =newDate(startDate.getTime());5let remaining = daysToAdd;6// ...advance the calendar until `remaining` working days have been consumed7}
remaining = daysToAdd. Ask for 2, get 2 working days later. Nothing surprising.
The frontend does it like this — static/ppm-ui/src/utils/date-utils.js:
js
1/**
2 * Add N working days to a date.
3 * Duration is 1-indexed: 1 day means same day (start=end).
4 */5exportfunctionaddWorkingDays(startDate, workingDays, ctx){6if(workingDays <=0)returnnewDate(startDate.getTime());7const d =newDate(startDate.getTime());8let remaining = workingDays -1;// 1-indexed9// ...10}
remaining = workingDays - 1. Ask for 2, get 1 working day later.
Both are right. The frontend one is 1-indexed because that is what a duration means to a planner: a task with a duration of 1 starts and ends on the same day. It is the convention the whole UI is built around, and changing it would be a far larger and riskier edit than the one this article is about. The backend one is 0-indexed because that is what "add N days" means to a programmer. Neither is a mistake. They are simply two different questions that share a function name.
They coexisted happily for a long time, because they were never asked to compute the same intermediate value. Then we added per-link lag.
The change that made them collide
The feature is ordinary MS-Project behaviour: a finish-to-start link can carry a lag of N working days, so the successor starts N working days beyond the usual adjacency instead of the day after. Commit 5dda6d5a, "Engine: per-link lag/lead support in BOTH cascade engines (parity-locked)".
Implementing it means a new helper in both date libraries. The backend one is the obvious thing:
js
1exportfunctiongetRequiredSuccessorStartWithLag(predecessorDueDate, lag =0, workingDays =DEFAULT_WORKING_DAYS, bankHolidays =newSet()){2const base =getRequiredSuccessorStart(predecessorDueDate, workingDays, bankHolidays);3if(!base ||!lag || lag <=0)return base;4returnaddWorkingDays(base, lag, workingDays, bankHolidays);5}
Now mirror it in the frontend. The instinct — the correct-looking, code-review-passing, shared-primitive instinct — is to write the same line:
js
1// looks right. silently puts the preview a day off what Apply writes.2returnaddWorkingDays(base, lag, ctx);
That is wrong, because thisaddWorkingDays advances N−1. Ask it for lag, get lag − 1 working days. The preview lands one day early on every lagged link in the plan.
The actual fix is one character, and the comment around it is doing more work than the code:
js
1/**
2 * Per-link lag variant — the successor start required by ONE predecessor given a
3 * finish-to-start lag of `lag` WORKING DAYS. lag<=0/absent = today's behaviour.
4 * Mirror of the backend getRequiredSuccessorStartWithLag. The backend advances
5 * `lag` via addWorkingDays(base, lag); this frontend addWorkingDays is off by one
6 * the OTHER way (advances N-1), so we pass `lag + 1` to advance the same `lag`
7 * working days. Both engines MUST land identically — locked by test/parity.
8 */9exportfunctiongetRequiredSuccessorStartWithLag(predDue, lag, ctx){10const base =getRequiredSuccessorStart(predDue, ctx);11if(!base ||!lag || lag <=0)return base;12returnaddWorkingDays(base, lag +1, ctx);13}
Success
Write the reason at the call site, not in a commit message. lag + 1 with no comment is indistinguishable from an off-by-one bug, and the next person to read it — including you, in four months — will "fix" it. A compensating constant needs its justification within eye range or it will not survive.
While we are here, one detail that is easy to get wrong in the rule itself. With several predecessors you compute the lag-adjusted required start per predecessor and then take the maximum. You do not take the maximum due date and then add lag. The repo's own comment flags this at src/services/calculation/chain-calculator.js:
Predecessor A due 10 Jan with lag 0 gives 11 Jan. Predecessor B due 8 Jan with lag 5 gives 14 Jan. Max-then-lag gives you 11 Jan; lag-then-max gives 14 Jan, which is the right answer. They only coincide when every lag is zero, which is exactly the state your existing fixtures are all in when you add the feature.
Why you cannot just make one engine the referee
The obvious escape is to have one source of truth. Compute on the server, have the browser ask.
You can't, for the reason above — a drag has to reflow locally. But there is a second reason specific to this app, recorded in commit f3dc0159: "the frontend computes the cascade, savePlanState persists those dates, and Apply writes them directly."
The frontend engine is the authoritative one. The backend engine is not re-run on apply. So the backend cannot referee the frontend, because on the write path the backend is not consulted at all. There is no single place to put the rule and no arbiter to appeal to.
It is worth knowing exactly how narrow the backend engine's job is, because it is narrower than it looks. Repo-wide, the backend cascade engine is imported by exactly two files:
bash
1$ grep-rn"calculation/" src/ |grep-v"^src/services/calculation/"2src/test-hook.js:18:import { recalculateFullPlan } from './services/calculation/engine';3src/resolvers/calculation-resolvers.js:2:import { recalculateFullPlan, recalculateFromIssue } from '../services/calculation/engine';
That is it. The hourly scheduled refresh does not settle plans — it fetches, transforms and shards, and runs no cascade at all. The avi:jira:updated:issue trigger borrows only the backend working-day primitive to validate one issue and revert it if someone moved a protected task's start date earlier than its dependencies allow; it does not settle a plan either.
So you have a second implementation of your most important rule, used on two code paths, which cannot referee the first one. You cannot delete it and you cannot promote it. You can only keep it honest.
The harness: fixed point, not equality
The instinct is to assert the two engines produce equal output. Run both on a fixture, deepEqual the results.
That test is wrong, and it is worth being precise about why. The two engines are supposed to differ in their intermediate conventions. An equality test over their internals fails constantly for reasons that are not bugs, so you end up whitelisting differences, and a whitelist is where real drift goes to hide. Equality is also the wrong shape at the boundary: the backend rolls up subtask children but not Epic-to-child, so a naive whole-plan equality assertion fails on hierarchy for a reason that has nothing to do with dates.
The property that actually matters is narrower and stronger. Since Apply writes the frontend's previewed dates directly, what you need to guarantee is:
Settling the frontend's preview with the backend engine must move nothing.
The preview must be a fixed point of the backend engine. If the backend would move it, the two disagree about that plan, and the preview is lying about what Apply will write. Here is the whole test, from test/parity/parity.test.mjs:
js
1for(const fx ofFIXTURES){2test(`parity (preview is a backend fixed point): ${fx.name}`,()=>{3const fe =cascadeFromIssue(fx.issues, fx.edit.key, fx.edit.changes, feCtx);4 assert.ok(fe,'frontend cascade returned a result');5const previewProj =project(fe.issues);67// Re-settle the frontend preview with the backend full-plan engine.8const settledProj =project(backendFullPlan(fe.issues, beCtx));910 assert.deepEqual(settledProj, previewProj,11`backend would move the frontend preview for "${fx.name}"\nPREVIEW: ${JSON.stringify(previewProj)}\nBACKEND-SETTLED: ${JSON.stringify(settledProj)}`);12});13}
This tolerates every intentional convention that is supposed to cancel, and fails the moment one stops cancelling. It also gets you idempotence for free, which is a property you want anyway.
The suite runs that shape three times over 14 fixtures — a fixed-point check on the single-edit path (cascadeFromIssue), a fixed-point check on the full-recompute path (cascadeAll, a separate code path with its own copy of the lag-aware rule, used on calendar changes), and a recorded regression snapshot. 42 tests.
Proving the harness actually works
A regression test nobody has watched fail is not yet a regression test. So I ran two mutations against it this morning.
Some context first, because the honest framing matters. The commit log has plenty of numbers in it, and I am not going to quote those as if I had observed them. What I can observe is the parity suite, because it turns out to be runnable in a bare checkout — it has zero npm dependencies. It uses Node's built-in test runner, imports the two engines directly, and needs nothing installed. Everything below is from npm run test:parity on a Mac Studio M3 Ultra, Node v24.15.0, on 14 August 2026.
I reverted the fix, replacing addWorkingDays(base, lag + 1, ctx) with addWorkingDays(base, lag, ctx) in the frontend. This is exactly the code a careful developer writes when mirroring the backend helper.
text
1ℹ tests 42
2ℹ pass 24
3ℹ fail 18
Eighteen failures: the six lag fixtures, times all three checks. And the failure message is the point — this is what the bug looks like when a test finally says it out loud:
text
1AssertionError [ERR_ASSERTION]: backend would move the frontend preview for
2 "lag: single link A->B lag=2 delays the successor"
3PREVIEW: {"B":{"startDate":"2026-06-12","dueDate":"2026-06-15", ...}}
4BACKEND-SETTLED: {"B":{"startDate":"2026-06-15","dueDate":"2026-06-16", ...}}
The preview says B starts Friday 12 June. The backend says Monday 15 June. One working day apart, three calendar days apart because a weekend is in the way, and in the product this is a milestone silently landing in the wrong week. Note also that the 24 passing tests are genuinely passing — every non-lag fixture is unaffected. If your fixtures had no lag in them, this bug ships.
Mutation B — the one a fixed-point test cannot catch
The interesting failure mode of a fixed-point test is that it only proves the two engines agree with each other. It says nothing about whether they agree with what the schedule is supposed to be. Change both engines in the same direction and they will happily agree on the wrong answer.
So I restored the fix and broke both at once — frontend lag + 1 → lag + 2, backend addWorkingDays(base, lag) → addWorkingDays(base, lag + 1). Both engines now advance one working day too many. They remain in perfect agreement.
text
1ℹ tests 42
2ℹ pass 36
3ℹ fail 6
All 28 fixed-point checks passed. Only the 6 regression-lock tests failed. That is the entire justification for the third check existing, and the code comment says so in advance:
js
1/**
2 * Regression lock: the exact settled output of the (authoritative) frontend
3 * engine per fixture. The fixed-point test above catches the two engines
4 * DRIFTING APART; this catches them drifting TOGETHER (a coordinated change that
5 * silently moves the schedule). Update deliberately if a behavior change is real.
6 */
Two different failure modes, two different tests, and neither one covers the other. The snapshots are the settled output of the authoritative engine, recorded — not hand-computed — with the arithmetic explained in a comment beside each one so that "update the snapshot" is a decision rather than a reflex.
Tip
Mutation-test your regression tests, especially the ones guarding an invisible property. It takes ten minutes: break the thing on purpose, confirm the suite fails, confirm it fails for the right reason, restore. If you cannot make a test fail, you do not know what it is testing.
Making two engines importable from one test
There is a practical obstacle worth covering, because it is the reason this harness did not exist earlier.
The frontend engine lived inside a React hook. You cannot import a React hook into node --test. So the first move in f3dc0159 was extracting the cascade out of useCalculation into a pure static/ppm-ui/src/hooks/cascade-core.js, leaving the hook as a thin wrapper. That is a refactor with no user-visible effect and it is the precondition for everything else — an engine that can only run inside a component cannot be tested against anything.
The second obstacle is duller and more annoying. Both engines use extensionless relative imports, because webpack and the Forge bundler both resolve them. Plain Node ESM does not. Rather than touching every import in the production source to satisfy the test runner, the harness registers a resolve hook:
The rule to take from this: bend the test runner to the production code, not the production code to the test runner. The moment you start adding .js extensions to satisfy a harness, the harness is no longer testing the thing that ships.
One more honest note about coverage. The backend scheduling engine has no unit tests of its own. The four backend test files cover AI-output parsing, JQL parsing and plan milestones — none of them touches the cascade. The backend engine's entire coverage is this parity harness. That is not ideal, but it is also not as bad as it sounds: the harness exercises it on every fixture, and it tests the property that actually matters rather than the one that is easy to assert.
Three more Forge-shaped traps from the same app
The two-engine problem is the deepest one, but three others cost real time and generalise past this app.
The thing the user clicks cannot be the thing that does the work
Indexing a plan means fetching potentially thousands of issues, discovering the hierarchy through several BFS rounds, transforming and sharding. It originally ran inside the indexPlan resolver, and commit e0642790 records the result: large plans timed out and errored.
There is no "raise the timeout" option here. Most Forge functions, Custom UI resolvers included, time out at 25 seconds; an async event consumer defaults to 55 and can be raised to a maximum of 900 via timeoutSeconds on the function module. The work has to change execution context entirely. The resolver becomes a dispatcher (abridged — comments and one status field trimmed for length):
js
1resolver.define('indexPlan',async({ payload })=>{2const{ planId }= payload;3const meta =await kvsStore.getPlanMeta(planId);4if(!meta)return{success:false,error:'Plan not found'};56// Flip to a non-terminal state immediately so a poller sees the job in flight.7 meta.status='queued';8 meta.updatedAt=newDate().toISOString();9await kvsStore.savePlanMeta(planId, meta);1011try{12await indexQueue.push({body:{ planId }});13return{success:true,queued:true,status:'queued'};14}catch(err){15// Couldn't enqueue — run inline so the plan still indexes.16console.warn('[PPM] Failed to queue index event, running inline:', err?.message || err);17const result =awaitrunIndexing(planId);18return{...result,queued:false};19}20});
That catch is the part worth stealing. The enqueue can fail — async events unavailable, or an installation not yet running a version that has the consumer at all — and without the fallback, shipping this is a flag day. With it, the app quietly degrades to the old synchronous behaviour and nobody notices. There is also a STUCK_MS of 15 minutes, after which a job sitting in queued behind a dead consumer is reported as errored so the UI can offer a retry rather than spinning forever.
Note
The commit that introduced this states that adding the consumer module is a major version bump requiring forge install --upgrade. I could not confirm that from the primary source, so do not take it from me either: Atlassian's app-versions page lists scope changes, CSP and egress changes, web trigger additions, adding or removing providers, and enabling licensing as major — it does not list ordinary module additions, and this change added no scopes. Check your own manifest diff against that page rather than assuming. It does not affect the design: the fallback is what makes the deploy safe either way, which is the actual point.
And the verification trick. How do you prove a fan-out dispatched to the right branch when you cannot attach a debugger to a Forge consumer? Commit 6d6a2733 did it by assertion on a field only one of the two candidate paths writes: run the consumer on a plan, then confirm the issue count and status are intact and lastIndexedAt is unchanged — because the other branch would have bumped it. Pick a field that exactly one code path touches and assert on that. It generalises to every context where you cannot get a breakpoint in.
One word, defined twice
The Dashboard said the plan had 210 tasks. Select-all in the table picked 203 rows. (Those figures, and the ones below, are from commit ba72c7d7's record of a 287-issue plan — history, not something I re-measured today.)
Both numbers came from the same app, and the cause was that "leaf" had two definitions. The Dashboard called a leaf an issue with an empty children[] — but the transformer fills children[] from subtasks only, so an Epic whose Stories attach via parentKey looked childless and counted as a leaf. The Table and Gantt used the real tree. Since leaves also drive percent-complete, overdue, at-risk and on-track, every Epic was double-counted: once as a leaf, again through its children's rollup. Complete read 77% when it was 68%.
The fix that matters is not the first one. It is a4a7e6bf, "the Epic-as-leaf bug was systemic", which went looking for the same mistaken definition elsewhere and found it in three more functions — computeWorkload, computeRiskScores and computeCriticalPath. All four now share one parentKeySet(list) helper and one isLeafIn(parents) predicate.
Two things generalise. When a definition turns out to be wrong, grep for the definition, not for the symptom — the symptom appeared on one screen, the defect was in four functions. And treat "two surfaces of my own app disagree about a count" as a defect report, because no unit test will ever generate that signal for you. This one was found by a human noticing 203 next to 210.
The layer that was in the DOM and invisible
The dependency-arrow SVG sat at zIndex:-1, painting behind an opaque background. Every arrow in the Gantt vanished. Commit 88561072 is blunt about why nothing caught it: "The jest tests only cover pure utilities and the window.__lz harness only checks engine STATE, so a purely-visual regression ... passed every test."
The elements were in the DOM. Their geometry was correct. Every assertion you would normally write — element exists, has a bounding box, has the right class — passed. The pixels were not there.
The fix was a new app-side suite that renders the real component in headless Chrome and proves each layer paints, by toggle-diff: screenshot the region, display:none only that layer, screenshot again. Byte-identical means the layer contributed nothing.
js
1const before =await page.screenshot({ clip });2await page.evaluate(hide, hideSelector);3await page.waitForTimeout(120);4const after =await page.screenshot({ clip });5await page.evaluate(show, hideSelector);6await page.waitForTimeout(80);78check(`${label}: actually paints pixels (toggle-diff)`,!before.equals(after),9 before.equals(after)?'region IDENTICAL with layer hidden — invisible!':'region changes when hidden ✓');
It uses display:none rather than visibility:hidden deliberately — some elements have CSS that re-asserts visibility on descendants, so visibility:hidden can leave pixels on screen. And like the parity harness, it was validated by reverting the original bug and confirming it fails.
The lesson is to assert the property — this layer contributes pixels — rather than the implementation, which would be zIndex > 0. A zIndex assertion locks one magic number and catches one bug. The toggle-diff catches the entire "in the DOM but invisible" class, including occlusion, stacking contexts and a foreground colour that matches the background. This suite sits below the live end-to-end harness in the ladder; if you want the rung above it, running real end-to-end UI tests against a deployed Forge app covers that one.
What this does not solve
A few boundaries, because a testing article that claims total coverage is not worth reading.
The parity harness proves the engines agree on 14 fixtures. It does not prove they agree on all plans — it is fixture-based, not property-based, and generating random valid plans with dependency graphs would be a stronger and more expensive test. The engines are also known not to be equivalent: the backend settle rolls up subtask children only, not Epic-to-child, and the parity fixtures deliberately exclude parent rollup on that path. The frontend is the authoritative engine; the harness proves the backend will not move what the frontend produced, which is a different and narrower claim than "the two engines are the same".
The write path has its own hardening that is out of scope here — a plan lock, a pre-check that nothing changed in Jira since editing began, writes chunked with a pause per issue, and a read-back comparison that refuses and keeps the draft rather than reporting a success it cannot verify. The pacing there exists because of Atlassian's points-based rate limits, which are their own subject.
And the app itself has boundaries I would rather state than have you find: workload reporting shows over-allocation but never moves anyone's work — there is no resource levelling, and the code says so in its own comment. Plan protection watches the start date only; a due-date-only edit on a protected issue is not evaluated. Apply does not survive closing the tab. One more note on provenance: the checkout these commits come from has no git remote, so I am describing what the code shows rather than what any released artefact contains.
The transferable part
Strip out the Jira specifics and this is a general result about duplicated rules.
You will sometimes be forced to implement one rule twice — a browser copy and a server copy, a fast path and a correct path, a client SDK and a backend validator. When that happens, the reflex is to test the two for equality and the reflex is wrong, because implementations that are supposed to differ in their internals will fail an equality test for reasons that are not bugs, and the whitelist you build to silence it is where the real drift will hide.
Test the property instead. Find the composition of the two that must be a no-op — apply one, settle with the other, assert nothing moved. Then add a recorded snapshot of the authoritative one, because a fixed-point test proves the two agree with each other and says nothing about whether they agree with reality. Then break both on purpose and watch which test catches which failure. If you have not seen it fail, you do not know what it does.
And write the reason for the compensating constant on the line above it. That is the cheapest of all of these and the one most often skipped.
If you are building something in this shape and would rather not discover the two-engine problem in production, that is the kind of work we do.
LeanZero Management: virtualising a 5,300-issue Gantt