Stay Updated

New tutorials, tips, and Atlassian insights. No spam, unsubscribe anytime.

L
LeanZero

An approachable expert helping teams simplify their Atlassian ecosystems. Sharing knowledge and building community, one solution at a time.

Services

  • Atlassian Migrations
  • Atlassian FastShift
  • Atlassian Maintenance
  • Forge App Development
  • AI Development Consultation

Topics

  • Jira
  • Jira Service Management
  • Confluence
  • Bitbucket
  • Atlassian Forge
  • Cloud Migration
  • Local AI
  • AI Coding
  • All topics

Company

  • Blog
  • Tutorials
  • Contact

Community

  • Join Discord
  • Support this site

© 2026 LeanZero. All rights reserved.

Privacy Policy|Terms of Service|Service Level Agreement|Trust Center
LZ·/PORTFOLIO·REV 2.6
  1. Home
  2. Portfolio
  3. Leanzero Management

Articles about LeanZero Management

LeanZero Management: virtualising a 5,300-issue Gantt
TutorialAtlassian ForgeLeanZero Management

LeanZero Management: virtualising a 5,300-issue Gantt

A 5,300-issue plan put 94,920 nodes in the DOM. Windowing the rows took it to 2,115 — and left a second full-height layer that windowing does not touch.

Aug 19, 202610 min read
LeanZero Management: two scheduling engines, one schedule
TutorialAtlassian ForgeLeanZero Management

LeanZero Management: two scheduling engines, one schedule

A Forge PPM app needs a live in-browser scheduler and a server-side one. They drift by a day, no test goes red, and the user believes the preview. Here is the harness that catches it — and the two mutations I ran to prove it works.

Aug 14, 202614 min read
LeanZero Management: the Jira dependency that actually moves dates
ArticleAtlassian ForgeJira

LeanZero Management: the Jira dependency that actually moves dates

A blocks link in Jira records a relationship. On Free and Standard it reschedules nothing. Here's the engine we put behind it, the required-start rule that's easy to get subtly wrong, and what it still can't do.

Aug 13, 202612 min read
Forge App for Jira Cloud
LeanZero Management logo

LeanZero Management

Portfolio planning inside Jira — a real scheduling engine on the issues you already have.

Get on MarketplaceTalk to us
Cascading dependencies Buffers & critical path Runs entirely on Forge
Your data never leaves Atlassian. The app runs entirely on Atlassian Forge. It declares no external endpoints and makes no third-party network calls — every issue, date and plan stays inside your Jira tenant.
Nothing is written to Jira until you approve it. Edits are staged locally and applied only from a review dialog that shows every date, dependency and rank move alongside its previous value. Untick a row and that issue is left untouched.

The problem with roadmaps in Jira

Most roadmap views draw bars. They do not reschedule. When a task slips, somebody opens a spreadsheet, works out what moves, and edits a dozen issues by hand — and the plan is stale again by the next standup. LeanZero Management puts an actual scheduling engine behind the bars, so moving one task re-plans everything that depends on it.

A LeanZero Management Gantt chart showing four projects, dependency arrows, an amber buffer and two milestone markers
One plan across four Jira projects. Epics roll up from their children, buffers are amber, finished work is green, and dependencies are drawn between the bars.

What the engine actually guarantees

Four rules, applied on every change, in topological order.

Successors wait for every predecessor

A task with three predecessors starts the working day after the LAST of them finishes — not the first. Merge points are where most roadmap tools quietly get the date wrong.

Working days, never raw calendar days

Weekends and holidays come out of the arithmetic, per plan. A five-day task starting Thursday finishes the following Wednesday, and a Sunday can never be a start date.

Buffers absorb slip instead of passing it on

Mark a task as a buffer and it holds its due date while its duration shrinks. Upstream overruns are consumed by the buffer until it is exhausted — only then do your delivery dates move.

Parents roll up from their children

An epic's bar is the MIN start and MAX due of everything under it, recomputed on every change. You never maintain a parent date by hand.

The same plan with the critical path highlighted in violet, running from the payments work through to the security sign-off
Critical-path mode: the chain with no slack, traced across all four projects, with everything else receding.

What you get

One timeline for the whole portfolio

Build a plan from a JQL query, a board, or whole projects — mixed together, across as many Jira projects as you need. Drag to reschedule, drag between bars to create a dependency, four zoom levels from day to quarter.

Critical path and baselines

See the chain with no slack driving your finish date. Freeze a baseline and every bar carries a ghost of where it used to be, so drift is visible rather than reconstructed from memory.

Plan health you can take apart

A weighted health score, on-track and overdue counts, schedule-risk bands, buffer health per buffer, milestone tracking and baseline variance. Every row opens the Jira issue behind it.

Safe for more than one planner

Per-plan roles, drafts, live presence and a write lock. Two people editing the same plan cannot overwrite each other, and a failed write stays staged so it can be retried rather than lost.

The plan health dashboard showing a health score, complete percentage, on-track and overdue counts, buffer health and milestones
Plan health: a weighted score, buffer health per buffer, and milestone dates — each row drilling through to the Jira issue.

Planning is safe because applying is deliberate

You can drag bars around all afternoon without touching a single Jira issue. Changes stage locally; applying them is an explicit, reviewable step.

The Review Changes dialog listing four date changes, a new dependency and a rank move, each with its previous value struck through
Every field, with its previous value struck through. Untick a row and that issue is left alone.

Write locks

Only one apply runs at a time per plan, and a conflict check catches anything that changed in Jira while you were planning.

Plan protection

Optionally guard a plan's dates against edits made outside it, so an approved schedule stays the approved schedule.

An optional AI second opinion

Semantic checks a rule engine cannot make, on Atlassian's own hosted models. Advisory, never a gate, and off by default.

The AI plan review listing a high, a medium and a low severity finding, each naming the issues involved
The AI review reads the plan and flags what the arithmetic cannot: work sequenced in an order the summaries contradict.

The manual

The complete reference: every scheduling rule with its arithmetic, every control on the timeline, every number on the dashboard and how it is computed, the permission model, the limits, and what to do when something does not behave the way you expected. 75 sections.

Every figure and formula here is taken from the app's own source and checked against it — the same content builds the documentation linked from the Atlassian Marketplace listing.

Contents
Getting started
  • 01What LeanZero Management is
  • 02Where the app appears in Jira
  • 03Creating a plan: the five wizard steps
  • 04The three source types and how they combine
  • 05How the hierarchy is discovered: children, parents and orphans
  • 06Which Jira fields are read, and which must be mapped
  • 07What indexing actually does
  • 08Plan statuses, the plan list, and the hourly refresh
How scheduling works
  • 09The Iron-Clad successor rule
  • 10Working days, holidays and the plan calendar
  • 11Duration semantics, and how a missing duration is recovered
  • 12Per-link lag
  • 13Buffers: due held, duration shrinks
  • 14Parents roll up; milestones are declared
  • 15The order the engine settles in
  • 16From gesture to Jira: what each edit does, and why the preview is what gets applied
  • 17Two worked examples
The timeline
  • 18The toolbars: every button
  • 19Zoom, the today line, and moving around the timeline
  • 20Moving a task, changing its length, and the difference
  • 21Dependencies: drawing, deleting, and lag
  • 22The complete bar-state legend: fill and outline are two separate axes
  • 23Milestones, parent brackets, guide lines and baseline ghosts
  • 24Critical path, auto-arrange, and the reorder suggestion
  • 25The Cascade Impact strip and the Explain dialog
  • 26Rows, hierarchy, and what happens on very large plans
Table view and plan health
  • 27The Dashboard: what it is and where the numbers come from
  • 28The plan health score and its exact weighting
  • 29Complete % — effort-weighted, and why it disagrees with the Done count
  • 30On track, at risk and overdue — the precise test
  • 31Schedule risk — the cascade-derived RAG score
  • 32Buffer health — per buffer and the pool figure
  • 33Milestone tracking
  • 34Baseline and variance
  • 35Resource workload per assignee
  • 36The two CSV exports
  • 37Table view: columns, the column picker and layout
  • 38Table view: sorting, the quick filter and group-by
  • 39Table view: selection, bulk edit, inline editing and the chips
Saving and applying
  • 40Staged, saved, applied — the three states of an edit
  • 41What Save writes, and what it deliberately refuses to write
  • 42Drafts — your unsaved work, and everyone else's
  • 43The Review Changes dialog
  • 44Apply — what actually happens to your Jira issues
  • 45When a write fails partway
  • 46The write lock and what other people see
  • 47Conflict detection — when an issue changed in Jira while you were planning
  • 48Live presence, live updates, and the polling fallback
  • 49Plan protection, and how to undo an apply
Administration
  • 50Roles, the capability matrix, and how a role is decided
  • 51Where permissions are actually enforced — and where they are not
  • 52The admin page and the Field Mapping tab
  • 53The Calculation Engine tab, setting by setting
  • 54The Display tab
  • 55Working-day calendars and holidays
  • 56AI features: what they do, what they cost, and how to switch them off
  • 57Maintenance: orphan cleanup and the destructive actions
Data, security and limits
  • 58What the app stores, and where
  • 59How sharding works
  • 60Where the app runs: Atlassian only, no data egress
  • 61Every OAuth scope, and why it is there
  • 62How and when a plan refreshes
  • 63What happens on a re-index
  • 64Limits — the real numbers
  • 65Who can see and change what, and how data is deleted
Help
  • 66Before you troubleshoot: where a change actually lives
  • 67Reading the timeline: what each bar is telling you
  • 68Troubleshooting: dates, dragging and the cascade
  • 69Troubleshooting: applying to Jira, locks and conflicts
  • 70Troubleshooting: indexing, freshness, permissions and performance
  • 71Messages, by exact wording
  • 72Frequently asked questions
  • 73Glossary
  • 74Every number in one place
  • 75What to collect before escalating

01What LeanZero Management is

The app is a portfolio scheduler that copies a filtered set of Jira issues into its own store, lets you reschedule them as a dependency-linked plan, and writes the result back to Jira.

LeanZero Management is a Forge app for Jira Cloud (app id ari:cloud:ecosystem::app/087a8e18-d45a-4cb7-9d87-3e84101ac4f3, Node.js 22 runtime). It solves a problem Jira itself does not: Jira stores a start date and a due date on each issue, but it has no notion of a schedule that reacts. Move one issue and nothing downstream moves with it, parents do not re-derive their span from their children, and there is no place to see a whole delivery as one timeline with dependency arrows.

The app's answer is a plan. A plan is a saved definition of where issues come from (JQL queries, boards, whole projects), plus a working calendar, plus a copy of every matching issue held in the app's own storage. Once a plan is indexed you get a Gantt ("the timeline"), a table view and a dashboard over that copy. You reschedule inside the app, the cascade recalculates successors and parent roll-ups locally, and only when you choose to apply does anything get written back to Jira issues.

The four objects you will meet

Plan
A named container. Holds its sources, a working calendar, optional milestones, a permissions model, and the indexed issue data. Created through a five-step wizard; identified by an id of the form plan-<base36 timestamp>-<6 random chars> (generatePlanId in plan-resolvers.js).
Source
One rule for pulling issues in: a JQL query, a board, or a project. A plan can have any number of them and they are merged.
Index
The run that fetches every source from Jira, discovers the surrounding hierarchy, converts each issue to the app's lean model, and writes it into storage in shards of 100 issues (SHARD_SIZE in kvs-keys.js). Everything the plan views show comes from this copy, not from live Jira.
Milestone
An optional target date (a launch, a gate, a go-live). Stored on the plan's meta document, drawn as a labelled diamond on the timeline, and fed to the AI health read-out. Explicitly NOT a Jira issue, not part of the cascade engine, and never written to a ticket.
NoteEdits are local until you save. usePlan's updateIssueDates changes only the in-browser issue array ("FRONTEND ONLY, no KVS writes"). A save calls savePlanState, which merges just four fields per issue into storage: startDate, dueDate, duration and buffer. It deliberately does not write back the whole client-side row, because doing so used to persist links you had only drawn and overwrite the stored Jira baseline (_original), the children/rank the indexer owns, and the lag map that lives in its own key.

Why the copy exists

Indexing exists so the timeline can be rendered and recalculated without hammering Jira. It is also what makes plan-level features possible at all: dependency filtering (a "Blocks" link only becomes a plan dependency when both ends are in the plan), parent roll-up, and a stored snapshot (_original) of each issue's start date, due date, duration, buffer and links AS THEY WERE AT INDEX TIME, so the app can show you what you changed since that point.

CarefulDo not confuse _original with "the baseline". _original is the automatic index-time Jira snapshot inside each stored issue and drives the changed-bar state. The dashed "was here" outline on the timeline comes from a SEPARATE, user-set baseline you freeze deliberately — a display-only snapshot of {startDate, dueDate, duration} in its own KVS key p:{planId}:bl (baseline-resolvers.js), capped at 2000 issues, which never enters the cascade or the write path.

02Where the app appears in Jira

Three user-facing surfaces (global page, issue panel, admin page) plus five background/platform modules, all served by one React bundle that routes itself from the Forge module context.

User-facing modules (manifest.yml)

ModuleManifest keyTitle shown in JiraWhat you get
jira:globalPageppm-dashboardLeanZero ManagementThe main app. Opens on the plan list; from there you create a plan, open a plan's Gantt, Table and Dashboard views, its Schedule view and its Permissions view. Layout: basic.
jira:issuePanelppm-issue-panelLeanZero Management PositionA panel on the Jira issue view showing which plans contain this issue and its dependency context.
jira:adminPageppm-admin-settingsLeanZero Management SettingsInstance-wide configuration. Five tabs: Calculation Engine, Field Mapping, Display, Plan Permissions, Maintenance.

Background and platform modules

ModuleKeyTrigger / budgetPurpose
triggerppm-issue-guardavi:jira:updated:issueRuns plan protection on the change, then incrementally syncs that one issue into every plan that contains it.
scheduledTriggerppm-hourly-refreshinterval: hourFans out one refresh event per plan onto the index queue.
consumerppm-index-consumerqueue ppm-index-queue, timeoutSeconds: 900Runs the heavy index/refresh work off the resolver's ~25s limit.
llmppm-llmmodel: claudeThe Forge LLM the AI features use (JQL builder, plan review/assessment). AI is off until an admin enables it.
webtriggerharness-test-stategated by HARNESS_SECRETDevelopment harness endpoint only; 404 in production.

What the admin page actually configures

Calculation Engine holds the dependency link type name (default "Blocks"), the engine limits (max cascade depth 10, max parent roll-up passes 5, max dependency-graph depth 15, max issues per calculation 150), the buffer impact prefix, and two indexing knobs (summary truncation 80, issues per storage shard 100). Field Mapping holds the field-id overrides. Display holds Gantt display preferences. Plan Permissions lists plans and their default access. Maintenance holds KVS clean-up plus the AI enable toggle, spend limits and a self-test. There is NO working-day calendar editor on the admin page — the wizard's Schedule step says "Add more calendars in Admin Settings", but nothing in the admin UI reads or writes cfg:working-days; only the saveWorkingDaysConfig resolver does.

How one bundle serves three surfaces

All three modules point at the same resource (ppm-ui, built from static/ppm-ui/build) and the same resolver function. On mount, App.jsx calls view.getContext() and branches on ctx.extension.type: jira:issuePanel routes to the issue panel, jira:adminPage routes to admin settings, anything else falls through to the plan list. The issue panel and the admin page are rendered without the app shell (no sidebar/nav chrome), because they live inside Jira's own chrome.

What the issue panel does

It reads the issue key from context.extension.issue.key, calls listPlans, then calls getIssue against every visible plan in parallel (one round-trip for all plans rather than N sequential ones). Plans where the lookup returns an issue are listed as memberships, and the first match supplies the dependency mini-view. If no plan contains the issue you get "This issue is not part of any PPM plan."

LimitThe issue panel only sees INDEXED data. An issue created or moved into a plan's scope since the last index will report "not part of any PPM plan" until that plan is re-indexed.

Permission scopes the app requests (manifest.yml)

  • read:jira-work, read:issue-details:jira, read:project:jira, read:jira-user — reading issues, projects and users
  • write:jira-work, write:issue:jira-software — writing dates and fields, creating/deleting issue links, and ranking issues on apply
  • read:board-scope:jira-software, read:board-scope.admin:jira-software — resolving boards and their backing filters
  • manage:jira-project, manage:jira-configuration — creating the PPM Duration / PPM Buffer custom fields and adding them to edit screens
  • storage:app — the app's own KVS store, where plans and indexed issues live

03Creating a plan: the five wizard steps

Exactly what each of the five steps collects, which ones block Continue, and what the plan record looks like the moment it is created.

From the plan list, "+ New Plan" (or the create card) opens the wizard. The stepper across the top shows all five steps; you can click back to any COMPLETED step, but you cannot click forward — every step you have not finished is a disabled button. The footer's back button is labelled with the previous step's title (e.g. "← Sources"), not the word "Back"; the top-left "← Back" button leaves the wizard entirely.

The five steps

#TitleHint under the titleCollectsBlocks Continue?
1NameWhat are you planning?Plan nameYes — the trimmed name must be non-empty
2SourcesWhere do the issues come from?One or more JQL / board / project sourcesYes — see the rule below
3ScheduleCalendar & visibilityWorking calendar + default access levelNo
4MilestonesKey target dates (optional)Zero or more name + date pairsNo
5ReviewConfirm & indexNothing — read-only summaryN/A (the button becomes "Create & Index")

Step 1 — Name

Heading "Name your plan". A single large input, autofocused, capped at 120 characters, placeholder "e.g. Q2 Release Plan". Pressing Enter with a non-empty name advances to step 2. Four suggestion chips fill the field on click: Q2 Release Plan, Platform Roadmap, Mobile Launch, Migration Wave 1. The name is not unique-checked and can be changed later via the updatePlan resolver.

Step 2 — Sources

The wizard starts with one empty JQL source labelled "Source 1". Each source card carries an editable label, a three-way type toggle (JQL / Board / Project), and a remove button that only appears when there is more than one source. "+ Add another source" appends a new JQL source labelled "Source N+1". The step's own header states the merge rule plainly: "Pull issues from a JQL query, a board, or a whole project. Mix as many as you like — they're merged into one plan."

The exact Continue rule for step 2

sourcesValid =
  sources.length > 0
  AND every source has its identifier filled in   (areSourcesValid, source-utils.js)
     jql     -> query.trim().length > 0
     board   -> boardId.toString().trim().length > 0
     project -> projectKey.trim().length > 0
  AND no JQL source is CONFIRMED invalid
     jqlValidById[source.id] !== false
     (null = empty or still validating -> allowed through)
TipA JQL query that is still being validated does not block you. Only a query Jira has actually rejected does. This is deliberate: the validity signal is debounced by 600 ms, so gating on "not yet known" would make Continue flicker as you type.

Step 3 — Working calendar and visibility

The calendar list is read from the working-days config (getWorkingDaysConfig, KVS key cfg:working-days). Each entry becomes a card showing its name and its working days spelled out (e.g. "Mon · Tue · Wed · Thu · Fri"), and the config's activeCalendar is preselected. The shipped defaults are standard — "Standard (Mon–Fri)", days [1,2,3,4,5] — and israel — "Israel (Sun–Thu)", days [0,1,2,3,4]. If the config cannot be read, the step falls back to a single hardcoded "Standard" option. The step's own sub-text invites you to "Add more calendars in Admin Settings", but no admin screen edits this config, so in practice you get the two shipped calendars unless saveWorkingDaysConfig is called directly.

CarefulGOTCHA: the calendar you pick in the wizard does not drive the schedule. It is stored on the plan as meta.calendarKey, and nothing in the scheduling path reads that value. The cascade and the timeline read getPlanCalendar, which loads the plan's own schedule document (p:{planId}:sched) and defaults to workingDays [1,2,3,4,5], calendarName "Standard (Mon-Fri)", holidays []. To actually change a plan's working days you must open the plan and use its Schedule view.

Visibility options (stored as plan.defaultAccess)

ValueCard titleCard descriptionEffect for a non-member
nonePrivateOnly you and members you inviteNo access; the plan is filtered out of listPlans entirely
viewerEveryone can viewRead-only for the whole siteCan view; cannot run the cascade, edit dependencies or apply
editorEveryone can editAnyone can change the scheduleCan view and edit
adminEveryone can administerFull control for the whole siteFull control, including delete and permission management

How a role is actually resolved

resolveRole checks, in order: Jira site/org admin (via mypermissions ADMINISTER) → plan owner (plan.createdBy matches a non-empty account id) → explicit entry in plan.members → legacy plan.editors array → the plan's defaultAccess (legacy plans with no defaultAccess fall back to 'viewer' if they have an editors array, otherwise 'none'). canView is any role other than 'none'; canEdit is admin, owner or editor; canDelete and canManagePermissions are admin or owner only.

LimitServer-side enforcement is selective, not universal. requireDelete gates deletePlan; requireEdit gates the calculation resolvers, the dependency resolvers — createDependency, deleteDependency, setLinkLag and rankIssue, the last of which writes real Jira rank via PUT /rest/agile/1.0/issue/rank and is gated like its siblings — and the Jira write/apply path; adminOnly gates the admin config resolvers. savePlanState, updateIssueDates and updatePlan carry NO permission gate — for those, the UI's own gating is the only barrier.

Step 4 — Milestones

Optional. Each row is a name (input capped at 80 characters) and a date picked with the app's own date picker. The empty state reads "No milestones yet — add a key date to anchor the plan, or skip this step." The wizard drops any row missing a name or a date before submitting, and the backend sanitizer then enforces the hard limits: at most 24 milestones per plan, names trimmed to 80 characters, dates must match ^\d{4}-\d{2}-\d{2}, ids truncated to 40 characters (a row with no id gets ms-<n>). Anything failing those rules is silently dropped.

Step 5 — Review, then Create & Index

The review page restates the plan name, the resolved calendar name (falling back to the literal string "Standard (Mon–Fri)" if the chosen key is not in the loaded list), the visibility title, then every source as a row of [type badge] [label] [detail] — where detail is the raw JQL, or "<board name> · #<id>", or "<project name> · KEY" — and finally the milestones that have both a name and a date. The primary button is "Create & Index" (it becomes a spinner plus "Creating…" while the flow runs).

What happens when you press Create & Index

  1. 1Status line shows "Creating plan..." and createPlan runs with { name, sources, calendarKey, defaultAccess, milestones }. On failure you get a toast: "Couldn't create the plan — check the source (JQL / board / project) is valid and try again."
  2. 2Status line becomes "Plan created. Indexing issues from N source(s)..." and a toast says "Plan created! Indexing issues..."
  3. 3indexPlan is called. It flips the plan to status 'queued' and pushes the job onto the ppm-index-queue, returning immediately.
  4. 4Because the job was queued, the status line becomes "Indexing issues in the background…" and the wizard polls getIndexingProgress every 1.5 s, giving up after 16 minutes (pollUntilIndexed).
  5. 5Result toast: "Indexed N issues" when polling reports 'indexed'; "Indexing is taking a while — it will finish in the background. You can open the plan now." whenever the job was queued but polling ended in anything else (timeout OR a reported error); "Indexing may have failed. You can retry from the plan view." when the job was never queued and returned no count.
  6. 6The wizard navigates you into the new plan.

Defaults written onto a brand-new plan (createPlan)

FieldValue on creationNotes
status'created'Shown as the badge "New" until indexing starts
version1Incremented on every save, every index that writes, and every incremental issue sync
issueCount / shardCount0 / 0Filled in by the first index
lastIndexedAtnullPlan card shows "Indexed never"
calendarKeythe wizard's choice, or 'standard'Stored but not read by the scheduler — see the callout above
holidayYears[current year, current year + 1]The wizard never sends this; plan holidays themselves live in the plan schedule document
includeParentstrue unless explicitly sent as falseThe wizard never sends it, so new plans get true; turning it on changes what a plan contains
protectionEnabledtrue unless explicitly sent as falseThe wizard never sends it either; enables the issue-updated guard that can revert an out-of-app date change
defaultAccessthe wizard's choice, or 'none''none' means private
members[]Add people later from the plan's Permissions view
createdBy / createdByNamecaller's accountId + display namecreatedByName is best-effort; a failed /myself lookup leaves it empty
sourcesre-identified as src-0, src-1, …The wizard's client-side ids are replaced, and query/boardId/projectKey are nulled when unused
TipIf AI is enabled by an admin, each JQL source also shows a "Build with AI" button. You describe the issues in plain English ("open bugs in PROJ assigned to me, newest first"), press Generate, and if the model returns a query you can press "Use this query" to drop it into the field — where the normal live validation checks it deterministically. The panel always carries the advisory "AI-generated — the query is validated below; review before using." If the daily or monthly AI cap is hit, the panel says "Daily/Monthly AI limit reached (used of cap)."

04The three source types and how they combine

JQL, board and project sources each fetch differently and validate differently; multiple sources merge by issue key with last-write-wins.

What each source type does at index time

TypeYou supplyJira call usedPaging
JQLA query stringPOST /rest/api/3/search/jql100 issues per page, followed via nextPageToken until Jira stops returning one
BoardA numeric board idGET /rest/agile/1.0/board/{id}/issue100 requested per page; startAt advances by the number actually returned, an empty page stops the loop, and the loop also ends once the accumulated count reaches the reported total
ProjectA project keyThe project key is turned into JQL: project = "KEY" ORDER BY rank ASC, then the JQL path aboveSame as JQL
NoteWhy the board pager looks odd: the Agile board endpoint caps its page size BELOW the 100 the app asks for. Advancing startAt by a constant 100 therefore skipped issues, and an empty page looped forever into a Forge timeout. It now advances by the real count and breaks on an empty page.

Project sources take the whole project

fetchIssuesFromProject accepts optional issueTypes and statusCategories filters, but both the indexer and the hourly refresh call it with an empty options object. A project source therefore always means every issue in that project, ordered by rank. If you want a subset, use a JQL source instead.

How JQL sources are validated as you type

Every keystroke restarts a 600 ms debounce, then calls validateJql, which runs POST /rest/api/3/jql/parse?validation=strict — as the viewing user first, falling back to the app identity on 401/403. If Jira reports parse errors the field shows "✗ <first error>" in red and Continue is blocked. If it parses, the app additionally asks POST /rest/api/3/search/approximate-count and shows "✓ Valid · ~N issues" in green. The count is best-effort: if the count endpoint is unavailable the query still shows as valid, just without a number.

How board and project sources are validated

Board and project sources use a picker instead of free text. Typing searches live (250 ms debounce) — boards via GET /rest/agile/1.0/board?name=<q>&maxResults=25, projects via GET /rest/api/3/project/search?query=<q>&maxResults=20&orderBy=name. Arrow keys move, Enter commits the highlighted row (or the raw text when nothing is highlighted), Escape closes. If nothing matches you can still commit raw text via a "Use "…"" button: a board id must be all digits, a project key is upper-cased for you. Once committed, a confirmation line shows the name plus "ID 123" or the key, with a × to clear it.

What the confirmation panel tells you (500 ms after you pick)

Board
The board's real name, its project name (falling back to the project key, then the board type), an approximate issue count computed from the board's BACKING FILTER's JQL, and that filter's JQL text shown verbatim underneath. If the board cannot be read you get "Board not found (or no access)" for a 404, otherwise "Jira returned HTTP <status>".
Project
The project name, then "KEY · style · lead <name>" on the second line (style is classic = company-managed or next-gen = team-managed), an approximate count from project = "KEY", and up to six pills naming the project's non-subtask issue types. If it cannot be read you get "Project not found (or no access)" for a 404, otherwise "Jira returned HTTP <status>".
CarefulThe counts shown in the wizard are NOT what the plan will contain. They are approximate counts of the source's own scope. The indexed plan is normally LARGER, because hierarchy discovery pulls in children the source never matched (and, with include parents on, missing ancestors too). A board's count in particular is the count of its backing filter, which may differ from what the board actually displays after its own sub-filters and swimlanes.

Mixing sources

Sources are fetched one after another in the order they appear on the plan, and every issue is put into a single map keyed by issue key. Duplicates are therefore deduplicated automatically, with the LAST source that returned an issue supplying the version that is kept. Because all three types produce raw Jira issue objects with the same requested field list, the versions are equivalent in practice.

LimitIn a FULL index, a source with a missing identifier fails the whole run: fetchFromSource throws 'JQL source "<label>" has no query', 'Board source "<label>" has no boardId' or 'Project source "<label>" has no projectKey', and runIndexing catches it and sets the plan to status 'error' with that message. The hourly refresh behaves differently — it skips a source with no identifier and catches per-source fetch errors, logging a warning and continuing with whatever it did get. The wizard prevents an identifier-less source at creation time; it can only appear on a plan whose sources were edited through the updatePlan resolver.

How the app behaves against Jira's rate limits

Jira requests made through the shared client — the index-time source fetches, issue field updates, link create/delete, ranking, bulk fetch and the current-user lookup — go through a retry wrapper: 4 attempts, 2 s base delay with exponential backoff capped at 30 s, and a random 0.7–1.3 jitter factor so retries from parallel work do not synchronise. A 429 honours the Retry-After header when present (also capped at 30 s). Server errors (5xx) are retried; other 4xx responses throw immediately, because retrying a bad request just wastes the quota. Requests outside that client are NOT retried and fire exactly once: the wizard's validation and picker calls (JQL parse, approximate counts, board/project/filter lookups), the hourly change probe, field auto-setup, the admin permission check and the settable-fields (editmeta) probe each treat a failure as their own fallback instead.

LimitThere is no UI for editing a plan's sources after creation. The SourceEditor component still exists but nothing renders it — the wizard only imports its JqlValidation helper — and no frontend file calls the updatePlan resolver at all. Sources, name, calendarKey, milestones, protectionEnabled and includeParents can only be changed by invoking updatePlan directly (it keeps any source id you supply and assigns src-N only to sources sent without one — unlike createPlan, which always re-identifies as src-0, src-1, … — normalises the unused identifier fields to null, bumps the plan version and refreshes the plans-list entry). Changing sources does not re-index by itself — run Re-index from the plan toolbar afterwards.

05How the hierarchy is discovered: children, parents and orphans

After fetching your sources the indexer walks DOWN the parent tree to pull in every descendant, and optionally walks UP to pull in missing ancestors; anything whose parent is still outside the plan gets an orphan badge on the timeline.

Why any walk is needed

The issues your sources return are rarely the whole tree. A board returns the issues on the board. A query like "project = X AND type = Epic" returns only the epics. Their stories and sub-tasks are missing, so the timeline would draw a parent bar with no rows under it and roll up nothing. Discovery closes that gap before anything is stored.

The downward walk (always runs)

Since Jira's 2023 field unification, the parent field points at an issue's parent one level up at EVERY level: initiative ← epic, epic ← story/task/bug, story ← sub-task. So a query of parent IN (<keys>) returns exactly the direct children of those keys, whatever the levels involved. The indexer runs that as a breadth-first search: every round queries the keys found in the previous round, and the walk terminates naturally when a round finds nothing new. It never needs to know your hierarchy level names or how many levels you have. The same walk runs in the hourly refresh, or an hour later every child row would be stripped out again.

The limits on the downward walk

LimitValueWhy
Keys per parent IN clause80 (PARENT_BATCH)JQL's practical value-list cap is around 1000, and the query is POSTed so there is no URL length limit; the batch is kept modest so each search call stays light and pages predictably
Maximum BFS rounds25 (MAX_ROUNDS)Real hierarchies are a handful of levels (sub-task to epic is 3; Premium custom levels add a few more). 25 is far above anything Jira supports and exists only to guard against an unexpected cycle
Failure handlingPer batchA batch Jira rejects is logged and skipped, so one bad key cannot abort the whole walk; a total discovery failure is warned and the index continues with just the source issues

Sub-task insurance

parent IN already returns sub-tasks, but as belt-and-braces for older or edge configurations the indexer also sweeps the subtasks[] array of every seed and every discovered issue, collects any key not already seen, and bulk-fetches them (POST /rest/api/3/issue/bulkfetch, 100 keys per chunk). It dedupes for free against what the walk already covered.

The upward walk — "include parents"

A source that matches a Story but not its Epic leaves that Story parentless, and the transformer nulls a parent key it cannot resolve — so the timeline never learns the item had a parent and draws it as a top-level row. That is the reported "why can I add a work item to the Gantt when its parent isn't there?". When a plan has includeParents === true, the indexer walks UP after the downward walk: parent is already in the fetched field list, so every issue carries fields.parent.key, and the app simply bulk-fetches the parents it is missing, then repeats for THEIR parents, up to the same ceiling of 25 rounds. A parent it cannot fetch is a warning that breaks the walk, never a failed index.

CarefulOrder matters and is enforced in code: ancestors are discovered AFTER descendants, and ancestor results are NEVER fed back into the downward walk. Pulling in an Epic and then walking down from it would drag in every OTHER story under that Epic and all their sub-tasks, exploding the plan to the transitive closure of the hierarchy.
LimitincludeParents defaults to true for every plan created through the createPlan resolver (the wizard never sends the flag), but there is no control anywhere in the UI that toggles it — the string does not appear in the frontend at all. It can only be changed by calling updatePlan with includeParents. Plans that predate the flag, or that were created with it explicitly false, keep the old behaviour until someone flips it through that resolver. The orphan badge's own tooltip says to "Turn on 'Include parents' in the plan's sources" — no such control, and in fact no source editor at all, currently exists in the UI.

What the orphan badge means

When an issue's parent exists in Jira but is not in this plan, the transformer nulls parentKey (the tree builder, the roll-up and the metrics all assume a parentKey resolves) but records the real key in orphanParentKey. The timeline renders that as a small monospaced pill on the row reading "↑ PARENT-KEY". Clicking it opens that parent in Jira. Hovering it says: "Parent <KEY> is not in this plan, so this item shows at the top level. Turn on "Include parents" in the plan's sources to pull it in." Without the badge an orphaned Story is indistinguishable from a genuine top-level item, and nothing explains why it is sitting at the root.

Hierarchy level

Each issue's level comes from issuetype.hierarchyLevel as Jira reports it. Only when Jira omits it does the app fall back to its own convention: sub-task = -1, everything else = 0. That fallback was previously wrong (it returned 1, labelling sub-tasks as epic-level); the corrected value matters because sub-tasks sit BELOW standard issues, not above.

What discovery does to plan size

Discovery is why a plan's issue count is usually higher than the count the wizard showed for your source. An epic-only JQL that previewed "~40 issues" can index several hundred once every story and sub-task under those epics is pulled in. If a plan is unexpectedly large, look at the shape of the tree under your seeds before blaming the query.

06Which Jira fields are read, and which must be mapped

The exact field list requested from Jira, the configurable field ids, what the app auto-creates on first index, and how each raw value is parsed.

Every field requested from Jira during indexing (getIndexFields)

Requested fieldConfigurable?Default idBecomes
summaryNosummarysummary, truncated to config.indexing.maxSummaryLength (default 80 characters)
issuetypeNoissuetypetype (the name) and hierarchyLevel
statusNostatusstatus (name) and statusCategory (the category key, or 'undefined')
parentNoparentparentKey when the parent is in the plan, otherwise orphanParentKey
assigneeNoassigneeassigneeName (display name)
Start dateYescustomfield_10015startDate
Due dateYesduedatedueDate
DurationYescustomfield_11581duration (working days, numeric)
BufferYescustomfield_12399buffer ('Yes' or 'No')
RankYescustomfield_10019rank — the Jira Software LexoRank string that drives timeline row order
issuelinksNoissuelinkspredecessors / successors, filtered by link type and plan membership
subtasksNosubtaskschildren, filtered to keys present in the plan
priority, labels, resolution, reporterNo(system)priority, labels, resolution, reporter
created, updatedNo(system)created / updated, truncated to the YYYY-MM-DD date part
customfield_10016Nocustomfield_10016storyPoints
LimitStory points are hardcoded to customfield_10016. Unlike start date, due date, duration, buffer and rank, there is no admin override for it. On an instance where story points live on a different custom field, storyPoints will always be null. A sixth configurable id exists in the config (bufferPercentage, default customfield_12421) but getIndexFields never requests it, so nothing about it is indexed.
NoteThe indexer requests the CONFIGURED field ids, not the hardcoded defaults. This matters on instances whose Start/Duration/Buffer/Rank fields sit on non-default ids: fetching the defaults would have left those values null and a subsequent write could then overwrite real data with nothing. The hourly refresh does the same.

What happens the first time you ever index (field auto-setup)

  1. 1The indexer checks areFieldsConfigured(): true only if a saved field config exists AND it has both a startDate and a duration id. If it is already configured, this whole step is skipped.
  2. 2It collects candidate project keys — every source's projectKey, plus every key it can pull out of a source's query with the pattern project = KEY (a single word, optionally quoted).
  3. 3It fetches every field definition in the instance (GET /rest/api/3/field) and indexes them by lower-cased name, preferring a custom field over a system field when names collide.
  4. 4Start date: looks for a field named "Start date" or "Start Date"; falls back to Jira's built-in customfield_10015. Due date: always the system duedate field.
  5. 5Duration: looks for "Duration" or "PPM Duration". If absent, it CREATES a number custom field named "PPM Duration" (type float, exactnumber searcher) described as "Working days duration field. Created by LeanZero Management for project portfolio management."
  6. 6Buffer: looks for "Buffer" or "PPM Buffer". If absent, it CREATES a select custom field named "PPM Buffer" (multiselect searcher) described as "Buffer status (Yes/No). Created by LeanZero Management. Buffer issues absorb schedule delays without affecting downstream work."
  7. 7It adds the start date, duration and buffer fields to the edit screen of each collected project — resolving project → issue-type screen scheme → screen scheme → edit screen → first tab — and ALSO, as a fallback, to the instance's "Default Screen". Then it saves the resolved ids to cfg:fields so every later operation uses them.
CarefulThe project-key scrape only understands project = KEY. A plan whose sources are boards, or JQL using project IN (A, B) or a filter reference, contributes no project keys — so the PPM Duration / PPM Buffer fields land only on the "Default Screen" fallback, which will not be the edit screen for many projects. If duration or buffer values never appear on your issues, check the edit screen configuration for that project first. Admins can override every field id from the admin page's Field Mapping tab instead of relying on detection.

How raw values are parsed

ValueRuleResult when absent/odd
DurationNumber kept as-is; string run through parseFloatnull if missing or NaN
BufferAn object with a .value (a select option) yields that value; a plain string yields itself'No' for anything falsy or unrecognised — buffer is never null
Start / Due dateTaken verbatim from the configured fieldnull
RankTaken verbatim (a LexoRank string)null — and getAllIssues then sorts that issue by its key instead, so legacy rows cluster predictably
SummarySliced to the configured maximum lengthempty string

Dependencies are filtered twice

An issue link becomes a plan dependency only if BOTH conditions hold: its type name equals the configured dependency link type (default "Blocks"), and the issue at the other end is also in this plan. An inward link makes the other issue a predecessor; an outward link makes it a successor. This is why the same Jira link can be a dependency in one plan and invisible in another — and why the incremental updater re-transforms an issue separately for each plan that contains it, against that plan's own key set.

The stored snapshot

Every indexed issue also carries an _original block holding the Jira values of startDate, dueDate, duration, buffer, predecessors and successors at index time. That is what the timeline compares against to decide a bar is changed. It is NOT the source of the dashed "was here" outline — that comes from the separate user-set baseline snapshot (p:{planId}:bl), whose tooltip reads "Baseline — <KEY> was scheduled …".

Issues with no dates

Nothing is invented at index time: an issue with no start date and no due date is stored with startDate: null and dueDate: null, and the timeline draws no bar for it. Two things then happen in the UI. First, a PARENT still gets a span, because the chart re-derives every parent's dates at render time from its descendants — earliest start, latest due, recursively — and records which descendant supplied each end so the popup can name them ("Earliest: X · Latest: Y"). Note that a parent's own stored dates are ignored whenever it has children inside the plan. Second, hovering the empty timeline row of an undated issue shows a dashed ghost bar five columns wide, labelled with the hovered day and four calendar days later; clicking it schedules the issue for real — the start snaps forward to the next working day, the due date becomes the FIFTH working day counting that start day itself, and duration is set to 5. Hovering or clicking a non-working day does nothing.

TipUndated issues are not excluded from the plan and not hidden. They occupy a row with an empty timeline until someone schedules them, which makes them easy to find: scan for rows with no bar.

07What indexing actually does

The full pipeline from queue push to stored shards, including the guard that stops a zero-match re-index from wiping a populated plan.

Why indexing runs in the background

Indexing is heavy — potentially thousands of issues fetched, a full hierarchy walk, a transform and a shard rewrite. A synchronous Custom UI resolver is capped at roughly 25 seconds, so large plans timed out. The indexPlan resolver therefore sets the plan to 'queued', pushes a {planId} event onto the ppm-index-queue and returns immediately; the consumer that picks it up has a 900-second budget. If the push itself fails (async events unavailable in that environment), the resolver falls back to running the pipeline inline so the plan still indexes — slower, and it may hit the 25-second limit on very large plans, but better than doing nothing.

The pipeline (runIndexing), in order

  1. 1Set the plan's status to 'indexing' and clear any previous status message.
  2. 2Run field auto-detection and creation if fields have never been configured.
  3. 3Load the admin config and build the list of Jira field ids to request.
  4. 4Fetch every source in turn, merging results into one map keyed by issue key.
  5. 5Walk DOWN: discover every descendant across all hierarchy levels, adding any key not already present.
  6. 6Walk UP, only if the plan has includeParents === true: discover missing ancestors, adding any key not already present.
  7. 7Transform every raw issue into the lean stored model (this is where dependencies, children and orphan markers are computed, in two passes so the full key set is known).
  8. 8Write the issues into shards of 100, five shards at a time in parallel, plus a key→shard index. Shards left over from a plan that SHRANK are deleted, so they cannot linger in storage (they used to survive even plan deletion).
  9. 9Prune per-link lags whose edge no longer exists in the freshly built graph — a link deleted directly in Jira vanishes from the rebuilt shards, and without this its lag would linger and silently reapply if the link were recreated.
  10. 10Rewrite the plan's entry in the plans:scope reverse index — the project keys of everything just indexed (an empty entry when the sources returned nothing) — so the issue-updated trigger can skip this plan for issues in projects it does not cover.
  11. 11Stamp the plan: status 'indexed', issueCount, shardCount, lastIndexedAt, updatedAt, a content fingerprint, and version + 1. Update the plans list entry.
  12. 12Publish an index:done realtime event so any open plan view stops polling and loads the fresh data immediately.
NoteThe content fingerprint is stamped at the end of a full index specifically so the very next hourly refresh can already skip the rewrite if nothing moved. Otherwise the hourly gate would have to pay for one complete re-index before it could ever engage.

The zero-match guard

If a re-index matches NOTHING but the plan previously had issues (issueCount or shardCount above zero), the app keeps the previous data. The plan stays 'indexed', its version is bumped, lastIndexedAt is left untouched, and it carries the message: "The last re-index matched 0 issues — the previous data was kept. Check the plan's sources." This exists because the old behaviour set shardCount to 0 and reported success — and since the issue reader trusts shardCount, the plan instantly rendered as empty, the whole schedule apparently gone, from nothing worse than a transient permission blip, a moved test issue or a JQL that stopped matching. The shards were not even deleted, just orphaned: the data was still there and unreachable. A plan that has genuinely never had issues still ends at 'indexed' with a count of 0, which is the correct empty state.

Failure handling

runIndexing catches its own errors, sets status 'error' with the error message as statusMessage, and emits an index:error event. It deliberately does not re-throw: a re-throw inside the queue consumer would make the platform retry the job repeatedly for the whole retention window, which is exactly wrong for a permanent failure such as a malformed JQL. Transient Jira errors (429 and 5xx) are already retried lower down, inside the Jira client.

The numbers that govern how long an index takes

KnobValueWhere it bites
Issues per search page100One POST per 100 issues for JQL and project sources
Board page size100 requested; Jira returns fewerOne GET per returned page
Parent keys per discovery query80Number of search calls per BFS round = ceil(frontier / 80)
Maximum BFS rounds25Hard ceiling; real hierarchies terminate in a handful
Bulk fetch chunk100 keysUsed for sub-task insurance and for the ancestor walk
Issues per storage shard100A hard constant (SHARD_SIZE in kvs-keys.js). The admin page exposes an "Issues per Storage Shard" setting, but nothing reads it — the value is inert
Parallel shard writes5Shards are written five at a time
Consumer budget900 sHard cap on one background index
Inline fallback budget~25 sOnly used when the queue push fails
Stuck-job threshold15 minutesA plan sitting 'queued'/'indexing' with untouched meta beyond this is reported as errored
UI wait ceiling16 minutes, polled every 1.5 sHow long the wizard and the plan view wait before telling you it will finish in the background

So how long is it really?

There is no fixed figure in the code, and the honest answer is that it scales with the number of issues, the shape of the hierarchy beneath them, and Jira's own responsiveness. Use the table above to estimate: a plan of a few hundred issues in a shallow tree is a handful of Jira calls and a handful of shard writes; a plan of several thousand issues across several hierarchy levels means dozens of search pages plus one BFS round per level, and is where the 900-second consumer budget starts to matter. If a job exceeds 15 minutes without touching the plan record it is treated as dead and offered for retry.

TipYou do not have to wait. Once the wizard reports the job is queued you can open the plan; the view subscribes to the index:done event and repopulates itself when the consumer finishes (replayed duplicates of the same version are ignored).

08Plan statuses, the plan list, and the hourly refresh

What each status value means, what the plan card tells you, and the gates that decide whether the hourly background refresh does any work at all.

Plan status values

Stored valueBadge labelBadge description
createdNew"Plan created but not yet indexed. Click Re-index to load issues."
queued(see gotcha)No badge entry — the index job has been pushed to the queue but the consumer has not started it.
indexingIndexing"Fetching issues from your Jira sources. Please wait..."
indexedReady"All issues are indexed and the plan is ready for editing."
calculatingCalculating"Running chain calculation engine on the dependency graph."
writingWriting"Writing changes back to Jira tickets. Do not close the app."
errorError"An error occurred. Try re-indexing or check Admin Settings." statusMessage carries the real reason.
CarefulGOTCHA: 'queued' is not even in the PLAN_STATUS constant, let alone the status badge's configuration table, so a plan sitting in the queue displays the fallback badge "Unknown" with the tooltip "Status not recognized." It is not an error — the job is simply waiting for a consumer. It becomes "Indexing" the moment the consumer starts.

What getIndexingProgress returns

The progress resolver returns the plan's status, statusMessage, issueCount and lastIndexedAt. It also self-heals: if the plan is still 'queued' or 'indexing' and its record has not been touched for more than 15 minutes, it reports status 'error' with "Indexing timed out — please retry." rather than leaving the UI wedged on a job whose consumer died. It only reports that status; the stored plan is left alone.

The plan list

listPlans enriches each plan with its source count and types, the full sources array, lastIndexedAt, creator, members, defaultAccess, draft count, issue count, status, and YOUR resolved role — and then filters out every plan you cannot view. Each card shows three numbers (Issues, Sources, Drafts — the Drafts figure turns amber when there are any), one pill per source whose tooltip carries the raw JQL, "Board ID: <id>" or "Project: <KEY>", and a footer of "Updated <relative>" and "Indexed <relative>". Relative times read "just now" under a minute, then Nm / Nh / Nd ago, then an absolute locale date past seven days, and "never" when the timestamp is null. The card's ⋮ menu offers "Delete plan", which asks "Delete plan "<name>"? This cannot be undone."

LimitDeleting a plan touches nothing in Jira — issues, dates and links stay exactly as they were last applied — and there is no undo. In the app's own storage it IS a complete wipe: deleteAllPlanData removes the plan meta, the key→shard index, the dependency-lag map, the drafts registry, the write lock, the plan schedule, the user-set baseline, the presence roster, the plan's entry in the plans:scope reverse index and every recorded shard, then finishes with a residue sweep of anything still under the p:{planId}: prefix — per-user draft keys (p:{planId}:d:{accountId}), shards beyond the recorded count, keys a future version might add — before the plan is dropped from the plans list. The admin purge runs the same per-plan delete, so neither path leaves residue behind; the admin "Clean up KVS" action exists to reclaim keys that plans deleted by OLDER builds (whose delete removed less) left orphaned.

The hourly refresh, and why it usually does nothing

The scheduled trigger runs once an hour. It does not refresh plans itself: it fans out one queue event per plan ({ planId, reason: 'scheduled' }), batched at 50 events per push request, so each plan's refresh runs in its own 900-second consumer invocation. (It used to push one request per plan with Promise.all, which throttled past the documented 500-events-per-minute limit, rejected on the first failure without cancelling the pushes that had already succeeded, and then re-refreshed every plan inline — refreshing hundreds of plans twice an hour.) If the fan-out fails entirely, it falls back to an inline serial refresh only when there are 25 plans or fewer; above that it skips the tick, because an inline pass could not finish anyway and would duplicate work already queued. The consumer also GCs drafts abandoned for over 24 hours while it is there.

The checks a refresh must pass before it writes anything

CheckSkips with reasonRule
Recently indexedrecentSkipped if lastIndexedAt is under 55 minutes old. This exists to stop a plan you just re-indexed manually being redone by the next tick — not to space out the schedule.
BusybusySkipped if the plan's status is 'writing' or 'indexing'.
Gate 1: change probeunchangedOne cheap Jira count — POST /rest/api/3/search/approximate-count with (all source scopes OR'd) AND updated > "<lastIndexedAt, UTC, rounded down to the minute>". A count of 0 means nothing moved.
Nothing fetchedemptyIf every source came back with no issues, the refresh stops before transforming or writing, so it can never empty a populated plan.
Gate 2: content hashunchangedAfter fetching and transforming, the freshly built data is fingerprinted and compared with the stored fingerprint. Identical means nothing is written — not even a timestamp.
NoteGate 1 FAILS OPEN by design. An unprobeable source, an unreadable timestamp, a failed request or a missing endpoint all return "assume changed" so the refresh still runs. The reasoning is stated in the code: a missed refresh is a correctness bug, a redundant one is only cost. Board sources are specifically not probeable — their scope lives in a saved filter and resolving it would cost the very lookups the probe exists to avoid — and one unprobeable source makes the whole plan unprobeable, so a plan containing any board source always passes gate 1 and relies on gate 2.

What the content hash covers

The fingerprint is an FNV-1a hash over a sorted list of rows, one per issue, each carrying the issue key plus: summary, type, hierarchyLevel, status, statusCategory, parentKey, assigneeName, priority, resolution, storyPoints, startDate, dueDate, duration, buffer, rank, and the sorted predecessor, successor and children lists. Rows are sorted so Jira's return order is irrelevant, and the final value is prefixed with the row count. updated is deliberately excluded: Jira bumps it for changes the plan does not care about, such as a comment or a watcher, and including it would defeat the gate entirely — every commented-on issue would force a full shard rewrite.

TipGate 2 is what actually removes the cost. Forge bills storage by bytes and writes are roughly twenty times the price of reads, so rewriting every shard of an unchanged plan every hour was the app's single largest storage line item. Gate 1 catches the common idle case with one count; gate 2 catches what gate 1 structurally cannot see — an issue LEAVING the plan's scope by being deleted or edited so the source no longer matches it, which never moves any remaining issue's updated timestamp.

Two other things keep a plan fresh between hours

First, the issue-updated trigger: when any Jira issue changes, the app validates the change against plan protection (reverting it, with a comment, if the Iron Clad Rule was violated and the plan has protection enabled — in which case no sync follows), and otherwise re-fetches that one issue and updates it in every plan that contains it — transformed separately per plan so each plan's dependency filtering stays correct — then prunes stale lags, bumps that plan's version and notifies open views as an EXTERNAL change (notify, never silently overwrite someone's local edits). Second, the Re-index button in the plan toolbar, which runs the full pipeline on demand.

Re-index is safe for unsaved work

The toolbar's own help text states the contract: re-indexing — manual or the hourly background pass — pulls fresh Jira data for everything else and restores your unsaved edits on top from your autosaved draft. Because of that, Re-index asks for no confirmation. Use it when new issues should appear in the plan, when issues were modified directly in Jira, or when you simply want the plan to match Jira's current state.

CarefulA save can silently miss issues that are not in the plan's index — for example an issue created since the last index. savePlanState reports which keys it could not find, and the plan view surfaces that as "N issues couldn't be saved — not in the plan index yet. Re-index the plan." If you see that warning, re-index before saving again.

09The Iron-Clad successor rule

The single dependency rule the whole scheduler is built on: a successor starts on the next working day after its latest predecessor's due date, and nothing is allowed to sit anywhere else.

LeanZero Management schedules with exactly one dependency rule, called the Iron-Clad rule in the code. A dependency says "A blocks B". The rule says B must start on the next working day strictly after A's due date. It is not a suggestion the engine tries to honour — it is enforced on every cascade, on the issue you just edited as well as on everything downstream of it.

The rule, exactly

requiredStart(successor)
  = MAX over all predecessors P that have a dueDate of:
      lag(P->successor) = 0 : getNextWorkingDay( P.dueDate + 1 calendar day )
      lag(P->successor) = N : that same base, advanced N further WORKING days

getNextWorkingDay(d) = d itself if d is a working day, otherwise
                       the first working day after d

successor.startDate = requiredStart
successor.dueDate   = addWorkingDays(requiredStart, duration)
                    = requiredStart advanced (duration - 1) working days

Why each part is written that way

+ 1 calendar day, then next working day
The predecessor's due date is a day of work, not a handover moment, so the successor cannot share it. Adding one calendar day and then snapping forward lands on Monday when the predecessor finishes on a Friday, and skips any holiday in between (date-utils.js getRequiredSuccessorStart, mirrored by working-days/calculator.js getRequiredSuccessorStart).
MAX over all predecessors
A merge point (an issue with two or more predecessors) waits for the LATEST of them. Taking the maximum of the per-link required starts — rather than the maximum predecessor due date — is what makes per-link lag work correctly: with all lags at zero the two are identical, because the +1-working-day offset is monotonic (cascade-core.js requiredStartFromPreds).
Predecessors with no due date are skipped
requiredStartFromPreds ignores any predecessor that has no dueDate, and any whose date string does not parse. An undated predecessor constrains nothing, and a successor whose predecessors are all undated is never moved.
CarefulThe engine does not say "no earlier than". It says "exactly here". If you pull a predecessor's due date BACK, its successors are pulled back with it. Verified against the real engine: A 01 Jun→10 Jun blocking B 11 Jun→12 Jun, editing A's due to 03 Jun moves B to 04 Jun→05 Jun. There is no notion of free float that a successor is allowed to keep.
LimitThere is one dependency type in the product. Start-to-start, finish-to-finish and start-to-finish do not exist anywhere in the engine. A link counts as a dependency when the Jira issue-link TYPE NAME matches dependencies.linkTypeName (default "Blocks"); direction then comes from Jira's own link structure — the inwardIssue is the predecessor, the outwardIssue is the successor (src/services/indexing/issue-transformer.js). Links of any other type are ignored outright.

What happens when you try to break the rule

Any edit to an issue that has predecessors re-checks that issue's start. If the resulting start is not the required start, the engine overwrites it with the required start, rebuilds the due from the issue's duration, and raises a violation. This fires in BOTH directions — too early and too late are equally rejected.

Violation messages you will actually see

What you didWhat the engine doesMessage
Moved a blocked issue earlier than its required startSnaps it back to the required start and rebuilds its due from its durationB must start at predecessor's due + 1 day (2026-06-11). Cannot move before.
Moved a blocked issue later than its required startSnaps it back to the same required startB must start at predecessor's due + 1 day (2026-06-11). Cannot move after.
More than one violation lands inside the 100 ms toast windowSame snapping, one summary toast instead of severalN issues snapped to respect dependency rules

Violations are buffered for 100 ms and then shown as a single warning toast, so a burst of edits does not produce a burst of toasts. A single cascade raises at most one violation — the one on the issue you edited — so the plural message only appears when several edits land inside that window (PlanView.jsx onViolation).

TipTyping a later start date on a blocked issue will always be undone. The supported way to hold a successor back is a per-link lag (see the lag section), which the engine treats as part of the required start rather than as a violation of it.

10Working days, holidays and the plan calendar

Every date the engine produces is a working day; how the working-day pattern and holiday list are chosen, and what happens when you change them.

All scheduling maths runs on a working-day context: a set of weekday numbers (0 = Sunday … 6 = Saturday) and a set of holiday dates as YYYY-MM-DD strings. A day is a working day when its weekday is in the set AND its date is not in the holiday set. Dates are handled as UTC midnight throughout — the frontend parses with Date.UTC, the backend with a 'T00:00:00Z' suffix — so no timezone can shift a bar by a day.

Where the calendar comes from

LevelStored asDefaultUsed by
Per plan (the one that drives the timeline)KVS key p:{planId}:sched — { workingDays, calendarName, holidays[] }workingDays [1,2,3,4,5], calendarName "Standard (Mon-Fri)", holidays []The frontend engine — i.e. every bar you see and everything Apply writes
Admin defaults (cfg:working-days)activeCalendar plus named calendarsstandard = "Standard (Mon–Fri)" [1,2,3,4,5]; israel = "Israel (Sun–Thu)" [0,1,2,3,4]; plus any custom calendarsThe backend — both the recalculation engine (loadWorkingDayContext → engine.js) and the Plan Protection revert path (plan-protection.js, on the issue-updated trigger), which uses these calendars to compute the lag-aware required start when deciding whether to revert an external Jira edit on a protected plan
Bank holidays (admin)KVS key cfg:holidays:{year}emptyBackend only, and only for the CURRENT year and the next year (loadBankHolidays)

Holidays on a plan

Holidays are added to a plan's own schedule as { date, name } entries, kept sorted by date and de-duplicated on the date. A date must match YYYY-MM-DD exactly or the addPlanHoliday resolver rejects it with "Date must be YYYY-MM-DD". On the timeline a holiday column is drawn with a diagonal amber hatch (a -45° repeating gradient in rgba(251,191,36,…)) and a tooltip carrying the holiday's name, or "Holiday: YYYY-MM-DD" when it has none; weekends are drawn as a single repeating background layer per row rather than one element per day.

The frontend working-day primitives, and their exact semantics

getNextWorkingDay(date)
INCLUSIVE. If the date is already a working day it is returned unchanged. Otherwise it steps forward one day at a time, up to 30 steps.
addWorkingDays(start, N)
Returns the date N working days from start, counting start as day 1 — it advances N-1 working days. So addWorkingDays(Mon, 1) = Mon and addWorkingDays(Mon, 5) = Fri (Mon–Fri, no holidays), both verified. N <= 0 returns the start unchanged. Guarded at 5000 iterations.
workingDaysBetween(start, due)
INCLUSIVE count of working days from start to due. Mon→Fri = 5. Returns 0 when due is before start, and null when either date is missing.
Notesrc/services/working-days/calculator.js exposes calculateWorkingDaysBetween, which is END-EXCLUSIVE (start <= d < end). The inclusive figure comes from calculateDuration = max(1, exclusive + 1). Its addWorkingDays(d, N) advances a full N, so due-from-duration is calculateDueFromStartAndDuration = the start itself when duration is 1, else addWorkingDays(start, duration - 1). The conventions differ from the frontend's and are designed to cancel out — which is exactly what the parity harness exists to prove.

Snapping

Nothing is ever left on a non-working day. In a full recompute (cascadeAll) the engine first sweeps every issue: if its start falls on a non-working day the start moves forward to the next working day and the due is rebuilt from the duration; if only the due falls on a non-working day the due moves forward and the duration is recomputed from the new span. Verified against the engine: an issue starting Sat 06 Jun with duration 2 settles to Mon 08 Jun → Tue 09 Jun.

NoteWhen the plan's working days or holiday list changes, LeanZero Management runs a full recompute of every issue and shows the toast "Dates recalculated for new working day schedule". These moves are staged like any other edit — they are not written to Jira until you Apply.
Limit1) The full-recompute trigger compares the working-day set and the NUMBER of holidays (prevHols/newHols are Set sizes). If you remove one holiday and add another in the same update, the count is unchanged and the automatic recompute will not fire — reopen the plan, or make any edit, to re-settle. 2) A calendar with no working days at all cannot be scheduled, and the two engines fail differently: the frontend's getNextWorkingDay steps 30 days and returns that non-working date (verified: 2026-06-01 becomes 2026-07-01), while the backend's gives up after 14 iterations and returns the ORIGINAL date unchanged.

11Duration semantics, and how a missing duration is recovered

Duration is a 1-indexed count of working days, why duration 1 means start equals due, and the exact ladder the engine uses when Jira has no duration to give it.

Duration is the number of WORKING days an issue occupies, counted inclusively from its start. It is 1-indexed: duration 1 means the issue starts and finishes on the same day (start === due), duration 5 starting Monday finishes Friday. Duration 0 is not "no duration" — it is the declaration of a milestone (see the milestones section).

The conversions each engine uses

FRONTEND (authoritative — its output is what Apply writes)
  due      = addWorkingDays(start, duration)   // advances duration - 1 working days
  duration = workingDaysBetween(start, due)    // inclusive, 1-indexed

BACKEND (src/services/working-days/calculator.js)
  due      = calculateDueFromStartAndDuration(start, duration)
             = start when duration === 1, else addWorkingDays(start, duration - 1)
  duration = calculateDuration(start, due)
             = max(1, calculateWorkingDaysBetween(start, due) + 1)
  start    = calculateStartFromDueAndDuration(due, duration)
             = due when duration === 1, else subtractWorkingDays(due, duration - 1)
NoteThe frontend's addWorkingDays(d, N) advances N-1 working days; the backend's raw addWorkingDays advances N. Both are correct at their own call sites and are documented as such, and the lag helper compensates by passing lag + 1 on the frontend. This is precisely the class of bug the parity harness exists to catch — see the last section.

When an issue has no stored duration

Duration is read from a single Jira custom field (default customfield_11581, configurable) — issue-transformer.js reads it from nowhere else. A Jira project that never configured that field indexes EVERY issue with duration: null. Falling back to "1" in that situation would flatten every real multi-day task into a single point — and a flattened task then also satisfies the milestone test, so tasks would turn into diamonds mid-cascade. The engine therefore recovers a duration instead, using this ladder (effectiveDuration in cascade-core.js).

Duration recovery ladder, in order

  1. 1Take Number(_original.duration || duration) — the applied Jira baseline, falling through to the live value whenever the baseline is 0, null or absent — and use it if it is greater than 0. The baseline is preferred so that a half-edited issue still contributes its committed span.
  2. 2Otherwise, if the issue declares a zero duration, return 0 — a declared milestone stays a milestone. (A positive CURRENT duration overrides a zero baseline: isDeclaredZeroDuration returns false in that case.)
  3. 3Otherwise recover the issue's OWN working-day span from its dates — baseline start/due first, falling through per-field to the current dates — with a floor of 1.
  4. 4Otherwise return 1.
Notechain-calculator.js reads _original.duration in exactly one place — isDeclaredZeroDuration, the shared milestone test. Beyond that it uses the live duration, and when that is missing or non-positive AND no zero duration is declared, it recovers the span from the issue's PRE-MOVE start and due (origStartDate/origDueDate captured on entry). The declared-zero check runs first, so a zero duration — live, or a zero baseline with a null live value — keeps duration 0 with due = start instead of recovering a span. The two are held in lockstep by the parity harness rather than by being literally identical — including a dedicated fixture, 'no stored duration: successor keeps its own working-day span', where every issue has duration absent.

Import normalisation on load

Raw Jira imports frequently carry a duration that disagrees with their own dates (a calendar-day difference, or an estimate). On load, normalizeImportedDurations rewrites duration to the working-day span of start→due for every issue that has BOTH dates, parents included, using the plan's own calendar. It also syncs _original.duration to the same value, so the correction does not read as an unsaved edit and does not appear as a permanent phantom row in every Apply. Issues missing either date are skipped, and an explicit duration of 0 is left alone. This runs before the KVS snapshot is taken, so the corrected value becomes the baseline.

Editing an edge recomputes the duration

Any edit that supplies a start date or a due date causes the duration to be recomputed as the working-day span of the resulting start→due. Without this, dragging only the due edge left the stored duration stale and the number no longer matched the bar. Drags that already send an explicit duration round-trip to the same value, so the rule is safe for them too.

CarefulA cascade can push an issue's required start past its own old due date. The engine clamps this: on the backend the due is moved to the start and the duration becomes at least 1 (clamp M5 in chain-calculator.js); on the frontend the due is rebuilt from the duration with addWorkingDays, which cannot produce a due before the start. You will see a task collapse to a single day rather than draw backwards. The one exception is a dependency cycle, which can legitimately leave start after due — see the settle-order section.
LimitThe date editor's Duration field accepts positive integers only — its onChange rejects anything that is not /^\d+$/ with a value above 0. A zero duration therefore has to arrive from Jira's duration field; it cannot be declared from inside the editor.

12Per-link lag

How to hold a successor back by N working days without fighting the Iron-Clad rule, where lag is stored, and what it does to the required start.

Lag is a per-LINK delay measured in working days. It belongs to one dependency edge, not to an issue: the same successor can follow one predecessor immediately and another with a five-day lag. Lag is stored on the successor as predecessorLags[predecessorKey] = N, kept in the plan's own dependency key so a re-index cannot wipe it, and denormalised onto the issues on every load (kvs-store.applyLagsToIssues).

The lag formula

base   = getNextWorkingDay(predecessor.due + 1 calendar day)   // lag 0 — plain adjacency
lag N  = base advanced N further WORKING days

requiredStart = MAX over predecessors of that per-link value

Implementation note: the backend writes addWorkingDays(base, N); the frontend's
addWorkingDays advances N-1, so it writes addWorkingDays(base, N + 1). Both must
land on the same date — locked by test/parity, including a lag=1 boundary fixture.

What a lag of N does (predecessor due Wed 10 Jun 2026, Mon–Fri, no holidays)

LagSuccessor startReading
0 (or unset)Thu 11 JunThe next working day — plain adjacency
1Fri 12 JunExactly one working day past adjacency
2Mon 15 JunTwo working days past adjacency; the weekend is skipped, not counted
3Tue 16 JunThree working days past adjacency

Setting a lag in the UI

  1. 1Click the dependency arrow between two bars on the timeline. The link menu opens, headed "FROM blocks TO" (with a "staged" badge if the link itself has not been written to Jira yet), and carries a red "Remove link" button.
  2. 2Use the − / + stepper labelled "Lag (working days)". The current value sits between them, in the primary colour when it is above zero and muted grey at zero; the − button is disabled at zero.
  3. 3Every change re-settles the WHOLE plan immediately for live preview (cascadeAll), then persists the lag. Setting it back to 0 deletes the entry rather than storing a zero. If persisting fails, only that edge's lag is rolled back and re-cascaded, with the toast "Couldn't save lag: … — reverted." — and only if your optimistic value is still the current one, so a rapid re-edit is never clobbered.
LimitThe stepper clamps at 0 and both engines treat any lag of 0 or less as plain adjacency. There is no way to make a successor start before its predecessor finishes.
NoteWith every lag at zero, "latest predecessor" and "latest required start" are the same thing. Once lags differ they are not: a predecessor that finishes EARLIER but carries a large lag can be the one that binds. This is why the Explain panel refuses to name a single driving predecessor whenever a later-finishing predecessor exists — calling the binding link the "latest finisher" would be false (driverForIssue's anyLaterFinisher guard).

13Buffers: due held, duration shrinks

What marking an issue Buffer = Yes does to it during a cascade, what exhaustion means exactly, what it looks like on the timeline, and what it protects downstream.

A buffer is an ordinary issue with the Buffer field set to Yes (Jira field customfield_12399 by default; toggled in the date editor with the No / Yes buttons). It is a shock absorber: its due date is treated as an immovable deadline, so when a cascade pushes it, the start moves and the DUE STAYS PUT. The duration shrinks by exactly the amount of the delay.

Buffer cascade, exactly

fixedDue = _original.dueDate  (the APPLIED Jira baseline, falling back to the current due)

if requiredStart <= fixedDue:        // absorbing
     start    = requiredStart
     due      = fixedDue             // unchanged
     duration = max(1, workingDaysBetween(requiredStart, fixedDue))

if requiredStart >  fixedDue:        // EXHAUSTED
     start    = requiredStart
     due      = requiredStart        // the deadline is gone
     duration = 1
NoteBecause fixedDue comes from _original.dueDate, a buffer measures every absorption against the due date that is actually committed in Jira — not against a due it might have been given by an earlier unsaved edit. Successive cascades in one session therefore cannot quietly ratchet the deadline.

Buffer states and what you see

StateDatesBar
Healthy / absorbingStart moved later, due unchanged, duration reducedAmber gradient (#d97706 → #f59e0b) with an amber glow
Exhaustedstart === due, duration 1Violet gradient (#7c3aed → #a855f7), violet glow, and a 2 s pulsing animation
No due date at all (frontend, single edit)Start moves to the required start; the due and duration are left exactly as they wereWhatever it was — the engine has no deadline to hold or to collapse
No due date at all (backend, and the frontend's full recompute)With a duration: the duration is kept and the due is rebuilt from it (the backend tags the change cascade_buffer_no_due_use_duration). With neither: start = due = required start, duration 1Amber — this buffer had nothing to hold

What "exhausted" actually means

The condition
The required start computed from the buffer's predecessors is LATER than its fixed due date. There is no remaining span to give up.
The result
The buffer collapses to a single day at the required start. It stops being a deadline and becomes a point marker of where the chain now really is.
The consequence downstream
An absorbing buffer holds its due, so everything after it is untouched. An exhausted buffer's due moves, so the slip passes straight through to its successors. Verified against the engine: with A → BUF (fixed due 19 Jun) → Z, the first slip left Z untouched at 22 Jun→23 Jun; the slip that exhausted the buffer moved Z to 23 Jun→24 Jun.
How it is counted
In the cascade provenance a buffer is counted as either absorbed or exhausted, never both: absorbed means it shrank and survived, exhausted means duration <= 1 and start === due. The Gantt's own fill uses the slightly tighter duration === 1 && start === due.
CarefulWhen a buffer absorbs, both its dates can match the Jira baseline while its duration does not. The timeline treats a duration-only difference as a draft (a full 2 px amber outline — never a left rail) precisely for this case, and Apply will write the new duration. If the plan's Jira project has no duration field, that write is silently dropped — the absorption is then visible in LeanZero Management but has nowhere to live in Jira.
Limitsrc/services/calculation/parent-rollup.js skips any child whose summary starts with the buffer-impact prefix (default "[BUFFER IMPACT]", configurable in the admin Engine Config as "Buffer Impact Child Prefix"). The frontend's rollupParents has no such filter, and it is the frontend's roll-up that Apply writes — verified by running the engine, where a [BUFFER IMPACT] child dated 25–26 Jun did stretch its epic's bracket to 26 Jun.
LimitshouldCascadeThroughBuffer stops the backend's RECURSIVE successor walk at a buffer. Its topological pass still settles everything downstream afterwards, and the frontend has no such stop at all — it re-settles every successor in dependency order regardless.

14Parents roll up; milestones are declared

Parent dates are derived from children and can never be typed in; milestones are declared by a zero duration or a milestone issue type and stay points through every cascade.

Parent roll-up

A parent's dates are not scheduled — they are computed from its children, on every cascade, after the leaves have settled.

The roll-up formula

parent.start    = getNextWorkingDay( MIN over children of child.start )
parent.due      =                    MAX over children of child.due
parent.duration = workingDaysBetween(parent.start, parent.due)   // floored at 1

Details that matter

  • Children are found by parentKey, so an Epic whose Stories point at it is rolled up even though those Stories are not Jira subtasks. (The backend additionally unions in the subtasks array, so both hierarchy shapes behave the same.)
  • Roll-up walks UP the parent chain from every issue the cascade changed, parent before grandparent, so a grandparent bracket widens too.
  • A parent with no children in the plan is left alone.
  • Backend only: if the parent itself has predecessors and its dependency-driven start is later than its earliest child, that later start wins; and if the start then lands past the children's latest due, the parent collapses to a point (clamp M6).
  • A parent's bar is never drawn as a task bar. Inside the 22 px bar row it renders as a 5 px top line carrying the grey gradient #475569 → #334155, with two 8 × 10 px end brackets in 3 px solid #475569 and the issue key above it in 9 px uppercase.
CarefulParent bars cannot be dragged or resized — handleBarMouseDown returns immediately when isParent. Opening a parent in the date editor shows a "Rolled up" badge, read-only Start and Due values with "from KEY" chips naming the descendant driving each date (startSourceKey / dueSourceKey), no Duration or Buffer fields at all, no Apply or Clear button, and the line: "Dates roll up from this issue's children. Edit a child to change them." To move an epic, move its children.

Milestones

A milestone is something you DECLARE, not something a schedule change can accidentally create. There is one definition, in utils/milestone.js, used by the chart, the Explain panel and the harness alike — the rule previously existed in three disagreeing copies.

The milestone test, in order

ConditionResult
The issue is a parent (has children in the plan)Never a milestone — its dates are a roll-up
Buffer = YesNever a milestone — an exhausted buffer is its own state
No start date, or start ≠ dueNot a milestone — a milestone must be a single point
Duration is present (not null/undefined) and exactly 0Milestone
Jira issue type name is "milestone" (case-insensitive)Milestone

How milestones behave in a cascade

They move, but they stay points
A declared zero-duration milestone follows its predecessor to the required start and its due is set equal to that start. Locked by the parity snapshot: A 01→03 Jun blocking milestone M0 (duration 0) — pushing A's due to 10 Jun gives M0 11 Jun → 11 Jun, duration still 0.
Zero stays zero
A duration of exactly 0 is never "recovered" into 1. Both engines used to hand back 1 here, which un-declared the milestone — and because the frontend's preview is what Apply writes, that 1 was written to Jira. A dedicated parity fixture now locks this.
A milestone-typed issue with a real span is a bar
Verified by running the engine: an issue of type Milestone spanning 3 days cascades as an ordinary 3-day task (11 Jun → 15 Jun) and is drawn as a bar, not a diamond.
A one-day task is not a milestone
The old rule "start === due and duration <= 1" turned every one-day task into an unlabelled diamond, and turned ordinary tasks into diamonds mid-drag whenever a cascade collapsed them. Only a declaration counts now.
NoteMilestone ISSUES are rendered as a 13 px violet (#7c3aed) diamond centred on their bar — green #16a34a when the issue's status category is done — as a decorative overlay, so the bar keeps its interactions. Plan MILESTONE MARKERS are target dates held in the plan's own metadata and drawn across the timeline as a 2 px full-height #7C3AED guide line at 50% opacity, topped by a ◆ glyph and a solid violet pill carrying the marker's name (tooltip: "name · date"). Markers are not scheduled and never move — they are the fixed dates you are measuring the plan against.

15The order the engine settles in

Why the engine sorts topologically instead of walking successors, how a merge point is made to wait, and what happens with a dependency cycle.

Order is not a detail here — it decides the answer. A merge point that is settled before its longest predecessor reads a stale date and, in a plain depth-first walk, is never revisited. The engine therefore settles in dependency order, and there are two paths that do it slightly differently.

The two settle paths

PathTriggered byOrder used
cascadeFromIssue (single edit)A drag, an edge resize, a date-editor save, a Buffer toggle, connecting a link (which passes the predecessor's start date into the cascade)The successor-reachable set from the edited issue, sorted topologically with Kahn's algorithm over the edges inside that set. The initial zero-in-degree set is sorted alphabetically for determinism.
cascadeAll (full recompute)Changing the plan calendar, setting a link lag, a bulk field change from the table, overlaying a restored draftA snap pass over every issue, then repeated sweeps from every root until a whole sweep changes nothing, bounded at issueCount + 2 sweeps

Why each is safe

Topological order for a single edit
Kahn's algorithm guarantees every predecessor inside the affected set is settled before the successor that reads it, in one pass. A predecessor OUTSIDE the affected set cannot move, so it constrains nothing and is correctly ignored. Only the ORDER differs from a plain successor walk — the set of issues touched is identical to getAffectedChain's, so a cascade can never reach beyond the chain you edited.
Fixed-point sweeps for a full recompute
The full recompute reuses a depth-first walk, which can settle a diamond's join node early. Because the Iron-Clad cascade is monotonic (a start only ever moves later within a sweep), repeating the sweep until nothing changes converges on the same unique settled state the backend's topological pass produces. The bound exists purely so a cyclic graph cannot loop forever.
Backend ordering
The backend sorts the whole plan with edges predecessor→successor AND child→parent, so children are always calculated before the parent that rolls them up. Its INITIAL zero-in-degree queue is then sorted by Jira hierarchy level, highest first — and Jira's convention is sub-task -1, standard 0, epic 1, initiative 2+ — so epics come before stories before sub-tasks, with ties broken by key. That sort is only for determinism; correct child-before-parent ordering comes from the edges.

Merge points

An issue with several predecessors starts on the LATEST of their required starts, and only after all of them have settled. Locked by the parity fixtures: in a diamond A → B1, B2 → C where B2 is the long branch, pushing the fork root A to 10 Jun settles B1 to 11–12 Jun, B2 to 11–26 Jun, and C to 29 Jun — the next working day after B2, not after B1. A three-way fan-in behaves the same way: the join lands on 06 Jul, the next working day after the latest finisher B3's due of 03 Jul, whichever order the traversal happened to visit.

Engine limits the calculation code actually reads

SettingAdmin labelDefaultWhat it bounds
engine.maxCascadeDepthMax Cascade Depth10Recursion depth in the backend's calculateIssue / cascadeToSuccessors
engine.maxParentIterationsMax Parent Roll-up Passes5Roll-up passes in the backend's full-plan recalculation
MAX_SWEEPS (frontend, not configurable)—issue count + 2Full-recompute sweeps before giving up
LimitThe Engine Config screen also offers "Max Dependency Graph Depth" (engine.maxTraversalDepth, default 15) and "Max Issues in Single Calculation" (engine.maxIssuesPerTraversal, default 150). They are defined in config-loader.js DEFAULTS and rendered in the admin form, but a repo-wide search finds no calculation code that reads either value. Changing them has no effect on how a plan is scheduled.
CarefulYou cannot CREATE a cycle in the UI — the link check rejects a self-link ("Cannot link an issue to itself"), a duplicate link ("X already blocks Y"), and any link reachable back to its own source ("…would create a circular dependency"). But a cycle can arrive from Jira, and the engine must cope. Cycle members never reach in-degree zero, so they are appended to the end of the order and processed once. The contract is termination with well-formed dates, not sensible dates: a cycle is a logically impossible schedule. A cyclic issue can end up with its start after its due, which the timeline draws as a bar clamped to 6 px.
NoteVerified by running the engine on A → B → C → A with A's due dragged to 10 Jun: the edit is applied, then A's own predecessor C is immediately enforced on A, pushing A to 10 Jun → 19 Jun with the violation "A must start at predecessor's due + 1 day (2026-06-10). Cannot move before.", and B and C follow to 22–23 Jun and 24–25 Jun. It terminates, every date is valid, and the toast tells you the schedule fought back.

16From gesture to Jira: what each edit does, and why the preview is what gets applied

Exactly what a bar drag, an edge resize and a date-editor save send to the engine, and the proof that the dates you previewed are the dates written to Jira.

Drag versus resize — they are different operations

GestureHit zoneWhat it sendsWhat it means
Move (drag the middle of a bar)Anywhere except the outer 6 px{ startDate, dueDate }TRANSLATE the task. The start snaps to a working day in the direction of travel (right → next working day, left → previous), and the due is REBUILT from the task's own working-day span measured when you grabbed it.
Resize left (drag the left 6 px)Left 6 px of the bar{ startDate, duration }Change where it starts, keep the due. A step that would land the start on a non-working day is simply skipped.
Resize right (drag the right 6 px)Right 6 px of the bar{ dueDate, duration }Change where it finishes, keep the start. Same non-working-day refusal.
NoteShifting both endpoints by the same number of CALENDAR days silently changed the work content whenever the drag crossed a different number of weekends: Mon 3 Aug → Fri 7 Aug (5 working days) dragged 17 calendar days became Thu 20 → Mon 24, which is 3. The task quietly lost two days, and Apply wrote that wrong duration to Jira. A move now preserves the working-day span by construction.

Drag behaviour worth knowing

  • The 6 px EDGE constant decides the mode; the visible ew-resize cursor strips are 8 px wide and only render when the bar is wider than 20 px.
  • The bar follows the pointer as a separate preview element; the cascade runs exactly ONCE on drop, with the final dates — one ripple reveal instead of a recalculation per day-step.
  • Escape during a drag cancels it and restores the original position.
  • Parking the pointer near the edge of the timeline auto-pans, and the bar keeps stepping in whole days under the stationary cursor.
  • Whatever duration a resize sends is recomputed on commit as the working-day span of the resulting start→due, so the stored duration always matches the bar.
  • Issues moved by the cascade flash for 800 ms with a 2 px dashed #60a5fa outline, staggered by their depth in the cascaded set so the schedule visibly ripples; two edits inside that window do not cancel each other's flash.

Other ways to change the schedule

The date editor (click a bar)
Fields for Start, Due, a Duration input in days and a Buffer No/Yes toggle, a timeline summary line, the issue's dependencies under "Blocked by" and "Blocks" with a × remove button each (confirmed via a "Remove Dependency" dialog before staging), plus a Clear button and Apply / Cancel. Editing one field derives the others as you type (debounced 150 ms); on save only the fields that actually differ are sent to the engine, and Apply stays disabled until something is dirty.
Bulk set from the table
Multi-select rows and set a field once: it is applied to every selected row that has no children, in a single pass, and then the whole plan is re-settled once with cascadeAll. A loop of single edits would read stale state and only the last would stick.
Backend user-intent rules
The backend engine resolves a partial edit with an explicit decision matrix — start+due defines a WORKING-day segment (calendar days caused runaway downstream growth), start+duration computes the due, due+duration computes the start backwards, an inverted start+due collapses to a 1-day task at the start, and when all three are given and disagree the DATES win and the duration is recomputed.

Preview equals apply

Apply does not re-run the scheduler. It diffs each issue against its Jira baseline (_original) on startDate, dueDate, duration and buffer, and writes exactly those values to Jira in chunks of 10, with a delay between issues. The dates you are looking at are the dates that get written.

What the parity harness proves (95 tests over 19 plan fixtures — run and confirmed all passing)

  • The frontend preview is a FIXED POINT of the backend engine — settling the applied state with the backend moves nothing.
  • The frontend full recompute is likewise a backend fixed point.
  • Both engines, run independently on the SAME raw plan, produce identical dates — the only test shape that can catch the two engines mishandling missing input (for example a plan with no duration field at all).
  • The two frontend paths agree with each other: a full recompute does not move a settled single-edit preview, so a plan cannot change dates simply because you reloaded it.
  • A regression snapshot locks the exact settled output per fixture, catching the two engines drifting TOGETHER.

Explaining a cascade

After an edit, the Cascade Impact strip summarises the ripple — "This change moved N issues", plus the finish slip, buffers absorbed and an "N exhausted" chip — and offers an "Explain" button (tooltip: "Explain what this change did") alongside Details and dismiss. Everything it shows is a deterministic DIFF of the plan before and after, plus a recomputation of the binding predecessor on the final settled map — no language model is involved, so it cannot state anything the engine did not do. It reports the number of issues moved, the plan finish before and after and the slip in working days, buffers absorbed and buffers exhausted, milestones affected and milestones slipped later, and per moved issue its old and new dates, the signed working-day deltas, and which predecessor bound it. The list shows the 40 largest movers (sorted by absolute due-date delta) and says so when it truncates; the totals are always summed over the full set first.

The advisory headline you get, first match wins

ConditionSeverityHeadline
Your edit was rejected by a dependency and nothing else moved, slipped, absorbed or exhaustedblockedEdit blocked
Your edit was rejected but part of the plan still shiftedblockedEdit blocked
At least one milestone moved lateralertMilestone slipped
Plan finish slipped 10 or more working daysalertFinish slipped
A buffer went to zerocautionBuffer spent
Plan finish slipped 1 to 9 working dayscautionFinish slipped
A buffer shrank but survivednoticeBuffer absorbed it
A milestone was affected but none slipped laternoticeMilestone earlier
Plan finish pulled innoticeFinish pulled in
15 or more issues moved, nothing committed was hitcalmContained
1 or more issues movedcalmContained
Nothing moved—No panel is shown
NoteAfter the first match wins, an addenda pass pushes extra "consider" lines for damage the ordering would otherwise drop — a finish blown out by 10+ days, a buffer that also went to zero, and a note when the moved list was truncated at 40. Severity is bound to the data and is never touched by that copy.
LimitBefore writing each chunk, the app asks Jira's editmeta which fields are settable on that chunk's FIRST issue and filters every payload in the chunk to those. A duration or buffer field the project never configured is dropped rather than being allowed to 400 the entire update and take the date write down with it. A change that consisted ONLY of such a field becomes a no-op. If editmeta is unavailable the filter is skipped and all fields are attempted.

17Two worked examples

A three-link chain re-planned by one edit, and a buffer that absorbs a slip and then is exhausted by the next one — both run through the real engine.

NoteMon–Fri working week, no holidays. 2026-06-01 is a Monday. Mon 01, Tue 02, Wed 03, Thu 04, Fri 05 · Mon 08, Tue 09, Wed 10, Thu 11, Fri 12 · Mon 15, Tue 16, Wed 17, Thu 18, Fri 19 · Mon 22, Tue 23, Wed 24.

Example 1 — one edit re-plans a three-link chain

A blocks B, B blocks C. Nothing is a buffer, nothing is a milestone, no lags. You drag A's right-hand edge from Wed 03 Jun out to Wed 10 Jun. (This is the parity fixture "three-link chain A→B→C, push A due", so the numbers below are the engine's locked output.)

Before and after

IssueBeforeAfterWhy
A (edited)01 Jun → 03 Jun, duration 301 Jun → 10 Jun, duration 8Your edit. The duration is recomputed as the working-day span 01→10 Jun = 8.
B04 Jun → 08 Jun, duration 311 Jun → 15 Jun, duration 3Required start = next working day after A's due 10 Jun = Thu 11 Jun. Duration 3 is preserved, so due = 11 Jun advanced 2 working days = Mon 15 Jun.
C09 Jun → 11 Jun, duration 316 Jun → 18 Jun, duration 3Required start = next working day after B's new due 15 Jun = Tue 16 Jun. Duration 3 preserved → due Thu 18 Jun.

What the Explain panel reports for this edit (run and captured from the engine)

  • 2 issues moved (B and C), both classified as ordinary cascade moves; rippleCount 2.
  • Plan finish before 11 Jun, after 18 Jun — a slip of 5 working days.
  • B's binding predecessor is named as A (due 10 Jun, lag 0); C's is B (due 15 Jun, lag 0). Both are stated with confidence because each has exactly one binding predecessor, no later-finishing predecessor exists, and the settled start equals the required start. Each moved 5 working days on both edges.
  • No buffers and no milestones were touched, but the finish moved, so the headline is "Finish slipped" at CAUTION severity (the 1-to-9-working-day band). "Contained" is only reached when the finish does not move at all.
TipAdd a lag of 2 working days on the A → B link and B lands on Mon 15 Jun instead of Thu 11 Jun (adjacency 11 Jun, plus two working days, skipping the weekend), running 15 → 17 Jun and pushing C to 18 → 22 Jun. Put a holiday on Thu 11 Jun instead and B starts Fri 12 Jun — verified: A 01→10 Jun, B 12 → 16 Jun, C 17 → 19 Jun.

Example 2 — a buffer absorbs, then is exhausted

A blocks BUF, and BUF blocks Z. BUF has Buffer = Yes with a committed due date of Fri 19 Jun. Two successive slips are applied to A. Every figure below was produced by running the real cascade engine and its provenance/advisory layer.

Step 1 — A's due moves from 03 Jun to 10 Jun

IssueBeforeAfterWhy
A (edited)01 Jun → 03 Jun, duration 301 Jun → 10 Jun, duration 8Your edit.
BUF (Buffer = Yes)04 Jun → 19 Jun, duration 1211 Jun → 19 Jun, duration 7Required start = 11 Jun, which is still on or before the fixed due 19 Jun. The due HOLDS; duration becomes workingDaysBetween(11 Jun, 19 Jun) = 7. Five working days of slack were consumed.
Z22 Jun → 23 Jun, duration 222 Jun → 23 Jun, duration 2Unchanged. BUF's due did not move, so Z's required start is still Mon 22 Jun. This is the whole point of a buffer.

Explain, step 1

  • movedCount 1. Plan finish before 23 Jun, after 23 Jun — slip 0 working days.
  • Buffers absorbed: 1. Buffers exhausted: 0. Milestones affected: 0. BUF's row records 5 days absorbed and exhausted: false.
  • Headline "Buffer absorbed it" at notice severity: the buffer took the hit as intended, the finish and milestones held, and that path is now carrying less slack.

Step 2 — A's due moves again, from 10 Jun to 19 Jun

IssueBeforeAfterWhy
A (edited)01 Jun → 10 Jun, duration 801 Jun → 19 Jun, duration 15Your edit; duration recomputed as the 01→19 Jun working-day span.
BUF (Buffer = Yes)11 Jun → 19 Jun, duration 722 Jun → 22 Jun, duration 1Required start = next working day after 19 Jun = Mon 22 Jun, which is LATER than the fixed due 19 Jun. The buffer is exhausted: due collapses onto the start, duration 1.
Z22 Jun → 23 Jun, duration 223 Jun → 24 Jun, duration 2BUF's due moved for the first time, so the slip finally passes through. Required start = next working day after 22 Jun = Tue 23 Jun.

Explain, step 2

  • movedCount 2 (BUF and Z). Plan finish before 23 Jun, after 24 Jun — slip 1 working day.
  • Buffers absorbed: 0. Buffers exhausted: 1. The buffer's own row records 6 days given up and exhausted: true; it is counted as exhausted, not absorbed, so the same buffer is never double-counted.
  • Headline "Buffer spent" at caution severity, with the compound follow-up "And it still wasn't enough: your finish moved out anyway — you're now both later AND unprotected on that path."
CarefulBetween step 1 and step 2 the BUF bar changes from the amber gradient to the violet one, picks up the 2 s buffer-exhaust pulse, and shrinks to a single day. Nothing else announces that the slack on that chain is gone — the colour change and the Explain panel are the signal.

18The toolbars: every button

Two separate control rows sit above the timeline — the plan toolbar (shared with every view) and the Gantt's own control strip — and this lists what each button does.

LeanZero Management puts the timeline's controls in two rows. The upper row is the plan toolbar; the lower row is the Gantt-only control strip that also carries the year navigator. The split is deliberate: zoom and Critical used to live in the plan toolbar gated on the Gantt view, which made that toolbar wider in Gantt than in Table and wrapped it to a second row, so the whole header jumped every time you switched tabs. What that fix guarantees is a CONSTANT TOOLBAR HEIGHT, not identical contents: the view tabs and Assess render in all five views, but the action cluster (drafts badge, Save, Delete, Re-index, Apply) and the calendar badge are hidden in Dashboard, Schedule and Permissions.

Plan toolbar (upper row) — left to right

ControlLabel / stateWhat it does
Back←Returns to the plan list. Tooltip "Back to plans".
Plan name + statuse.g. "Mobile Launch" + a status badgePlan name and indexing status.
Counts"45 issues · 38 tasks"Total indexed issues, then schedulable tasks (total minus parents). The second figure is only rendered when it differs from the first. The ⓘ reads: "N issues indexed from your plan sources. M are schedulable tasks; K are parents (epics/stories) whose dates roll up from their children. The Gantt shows fewer rows if parents are collapsed."
Calendar badgee.g. "Standard (Mon–Fri)"The plan's working-day schedule. Rendered only when the plan has a named calendar, and hidden in Schedule, Permissions and Dashboard views.
View tabsGantt · Table · Dashboard · Schedule · PermissionsSwitches the main view. The active tab is filled (lz-btn-primary). Gantt/Table/Dashboard are remembered per plan in localStorage under ppm.display.viewMode.<planId>; Schedule and Permissions are never auto-restored.
PresenceLive indicator + avatarsRealtime connection status and who else is on this plan right now. Present in every view.
Assess✦ AssessRuns the AI plan assessment (health, risks, critical-path narrative). Tooltip "AI assessment of plan health, risks, and the critical path". Present in every view.
Drafts badge"N drafts"Other users' unsaved drafts on this plan. Shown only when N > 0, and hidden in Dashboard, Schedule and Permissions.
Save"Save (N)" / "Saving..." / "Saved"Persists the plan snapshot to storage only. Green and enabled when there are unsaved changes; disabled and dim when there are none, with a "just now" / "Ns ago" / "Nm ago" stamp beside it. Save does NOT write to Jira and does not change bar colours. Also fires automatically every 60 seconds when there are unsaved changes.
DeleteDeleteDeletes the plan, after a confirm dialog: title "Delete Plan", body "Delete plan \"NAME\"? This cannot be undone.", confirm button "Delete". Rendered only when you have delete permission.
Re-indexRe-index / "Indexing..."Re-fetches issues from the plan's JQL/board/project sources. Plans also re-index hourly in the background (scheduledTrigger interval hour in the manifest). Your unsaved edits survive: re-indexing pulls fresh Jira data and re-applies your autosaved draft on top — so there is no discard confirmation.
Apply"Apply N changes"Opens the Apply review dialog, which is what writes to Jira. Only appears when N > 0. It pulses for two 1.5-second cycles the first time it becomes available.

Gantt control strip (lower row) — left to right

ControlWhat it doesDefault / persistence
‹ / year buttons / ›Jump the horizontal scroll to 1 January of a year. The year currently under the left edge is filled in the primary colour with white text; the real current year is drawn in the primary colour but not filled when it isn't the visible one.Range is computed from the plan's dates (see the navigation section).
TodayScrolls so today's line sits 200px from the left edge. Red outline (#ef4444), fills red on hover.—
Day / Week / Month / QuarterThe four zoom levels. The active one is a filled primary button.Week. Not persisted — it resets to Week when you leave and re-enter the plan.
CriticalToggles critical-path emphasis. When on, the label becomes "Critical (N)" with N = number of zero-slack leaf issues.Off. Not persisted.
BaselineShows/hides the baseline ghost bars. This button only exists when the plan actually has a baseline snapshot.On (ppm.display.gantt.baselineGhosts).
Auto-arrangeReorders rows so dependency arrows stay short and point forward. View-only — Jira rank and the Table are untouched.On (ppm.display.gantt.autoArrange).
LegendOpens the bar-state key: 9 swatches drawn with the real bar treatments. Closes on outside click or Escape.—
TipThe sidebar (the row labels on the left) is resizable: drag the 5px vertical bar between the sidebar and the timeline. It clamps to 150–600px, defaults to 300px, and the width is written to localStorage under ppm.display.gantt.sidebarW when you release the drag.

Sidebar header

The sidebar header reads "Showing X of Y" — Y is every issue indexed from your JQL sources, X is how many rows are currently visible after hierarchy grouping. Collapsing a parent lowers X but not Y; the collapsed children are still part of the plan and still cascade.

Parent rollup guides (not in this strip)

The dashed vertical guide lines that drop from a parent bracket to the children driving its dates are controlled by a display preference (ppm.display.parentGuides, default on) exposed in the admin Display settings, not on the Gantt toolbar.

19Zoom, the today line, and moving around the timeline

The four zoom levels and their exact pixel scales, what the header shows at each, how the timeline range is chosen, and every way to scroll or jump.

The four zoom levels

LevelPixels per dayDay-row labelsMonth labels
Day40Every day numberShown (a month is 40 × days ≈ 1200px wide)
Week (default)14Only the first day of the work week, plus todayShown
Month5Only the 1st of each monthShown (~150px per month)
Quarter2.5NoneOnly where the month block is wider than 40px — at 2.5px/day a 30-day month is 75px, so they still show

A month label is rendered only when its block is wider than 40px (mg.count * pxPerDay > 40); otherwise the cell is left blank rather than clipping text. The "first day of the work week" is Math.min(...workingDays) — 1 (Monday) on a standard calendar, 0 (Sunday) on a Sun–Thu calendar — so week gridlines follow the plan's own calendar, not a hardcoded Monday.

Bar geometry at any zoom

Bar width
(dueX − startX) + pxPerDay — the due day is included, so a one-day task is exactly one column wide. An issue with only a start or only a due date gets a one-column bar.
Minimum drawn width
6px. A bar whose start is after its due date (a real data state) has a negative computed width and is clamped to 6px.
Row height / bar height
40px row, 22px bar (14px for a parent bracket).
Key label
Inside the bar when the bar is wider than 50px; otherwise printed just outside the bar's right edge. Nothing on the chart is ever an anonymous rectangle — the label used to be hidden outright below 50px, which is any task under 4 days at Week zoom, 11 at Month, 21 at Quarter, and every milestone.
Connector dots
Only rendered when the bar is wider than 12px.
Resize edge zones
Only rendered when the bar is wider than 20px.

How far the timeline extends

The range is not fitted to your data — it is padded by a full year on each side so you can always scroll into next year. Start = 1 January of (the earlier of the plan's earliest start year and the current year) minus 1. End = 31 December of (the later of the plan's latest due year and the current year) plus 1. With no dated issues at all it falls back to today −30 days to today +90 days before that padding. The year navigator lists every year from the range start to startYear + floor(dayCount / 365) + 1.

Ways to move

GestureResult
Vertical wheelScrolls rows (native — the wheel handler only intercepts Shift and Ctrl/Cmd).
Shift + wheelPans the timeline horizontally.
Ctrl/Cmd + wheelZooms. Wheel up = zoom in (finer). The date under the cursor stays under the cursor.
Zoom buttonsZooms while keeping the date at the LEFT EDGE fixed.
Today buttonScrolls to todayX − 200px (clamped at 0).
Year buttonScrolls to 1 January of that year.
Drag toward a viewport edgeAuto-pans while a drag is in flight. The pan zone is 44px from the edge and speed ramps with proximity up to 16px per frame. A bar move/resize pans horizontally only, a row reorder vertically only, and a dependency rubber band on both axes. The left inset skips the sticky sidebar.
NoteOn first load the timeline frames the plan's EARLIEST DATED BAR (scrolled to 120px left of it), not today. A plan whose work is mostly in the past would otherwise open on an empty window and read as broken. It falls back to today when nothing is dated. Press Today to jump forward.

The today line and non-working days

Today line
A 2px red (#ef4444) vertical line drawn in every row at todayIndex × pxPerDay + pxPerDay/2 — i.e. through the middle of today's column. It is drawn inside each row cell at zIndex 3, so it sits ABOVE the bars (zIndex 2) and the dependency-arrow layer, and below the milestone overlay (zIndex 4) and the sticky sidebar (zIndex 5).
Today in the header
Today's day number is red and extra-bold (font-weight 800). At Week zoom, today always gets a label even if it isn't the week-start day.
Weekends / non-working days
Drawn as one repeating 7-day CSS gradient per row rather than per-week divs. It follows the plan's working-day pattern, so a Sun–Thu calendar shades Friday and Saturday.
Holidays
Amber diagonal hatching (a −45° repeating gradient) with amber side borders, plus an amber-tinted header cell with an amber underline. Hovering either shows a tooltip labelled "Holiday" with the holiday's name (or Holiday: YYYY-MM-DD if it has no name). The cursor becomes a help cursor.
LimitZoom is component state in PlanView, not a saved preference. Leaving the plan and coming back returns you to Week. Scroll position is likewise not restored — it re-frames the earliest dated bar.

20Moving a task, changing its length, and the difference

Dragging the middle of a bar translates it and preserves its working-day span; dragging an edge changes its length — and the two use different arithmetic, which matters on non-Mon-Fri calendars.

Grab a bar anywhere and drag. Where you grab decides the mode: within 6px of the left edge is resize-left, within 6px of the right edge is resize-right, anywhere else is move. The cursor changes to grabbing for a move and ew-resize for a resize. (The visible ew-resize cursor zones on the bar are 8px wide while the mode test uses 6px, so the INNER 2px of that zone — the part furthest from the edge — still starts a move. Aim at the very edge.)

What happens during a drag

  1. 1The bar itself does not move. A separate translucent blue preview rectangle follows the pointer, positioned from the same geometry React uses for the real bar, so preview and committed result cannot disagree.
  2. 2Floating date labels appear at the bar's edges showing the candidate dates — start for a move or resize-left, due for a move or resize-right. They flip below the bar when the bar is within 80px of the viewport top.
  3. 3The drag steps in whole days: dayDelta = round((pointerDeltaX + howFarAutoPanScrolled) / pxPerDay).
  4. 4Nothing is recalculated while you drag. The cascade fires exactly ONCE, on drop, with the final dates — one ripple reveal instead of a recalculation per day-step.
  5. 5Press Escape at any point to cancel; the bar returns to where it was and nothing is staged.

Move vs resize — the arithmetic

MoveResize-leftResize-right
What changesBoth dates; length is preservedStart date + durationDue date + duration
Working-day spanMeasured once when you grab the bar and rebuilt from that span at the new start — a move can never change how many working days the task hasNot preserved — the task gets longer or shorter by designNot preserved
Landing on a weekend/holidayThe start snaps to the next working day IN THE DIRECTION OF TRAVEL (right = forward, left = backward), then the due date is rebuilt from the spanThe step is REJECTED — the bar appears stuck on that dayThe step is REJECTED
New durationUnchangedmax(1, calendarDaysBetween(newStart, dueDate) + 1)max(1, calendarDaysBetween(startDate, newDue) + 1)
CarefulA MOVE preserves the WORKING-day span; a RESIZE computes the new duration in CALENDAR days. This is a real asymmetry in the code. If you resize a bar across a weekend on a Mon–Fri calendar, the duration field it stages counts the weekend days. The move path was deliberately rewritten to avoid exactly this: it used to shift both endpoints by the same number of calendar days, so a task dragged 17 calendar days silently went from 5 working days to 3 and that wrong duration went to Jira on Apply.
LimitParent bars cannot be dragged or resized at all — the mousedown handler returns immediately for a parent. Their dates are a rollup of their descendants; edit a child instead. Clicking a parent bar opens a read-only popover that names which descendant supplies each end of the rollup.

Scheduling an undated issue

The hover ghost
An issue with neither a start nor a due date has no bar. Hover its row and a 5-column ghost bar follows your cursor — a translucent blue fill with a 1.5px dashed blue outline — with date labels at both ends. It refuses to appear over a weekend or holiday.
Click to schedule
Clicking the ghost schedules the issue: duration is set to 5 and the due date is 5 WORKING days after the start, inclusive (start counts as day 1). The start is passed through a next-working-day snap, though in practice that is a no-op because clicks on non-working days are blocked outright.
Gotcha
The ghost preview is 5 CALENDAR columns wide and its end label is start + 4 calendar days, but the click creates 5 WORKING days. On a Mon–Fri calendar, clicking on a Thursday shows a ghost ending Monday but produces a task ending the following Wednesday.

The date popover (click a bar)

Clicking a bar — as opposed to dragging it — opens a 320px popover anchored under the bar (it flips above and clamps horizontally near a screen edge). A drag never opens it: the click that follows mouseup is suppressed whenever the drag actually moved something. It closes on Escape or an outside click (clicks inside the date-picker's own portal are excluded).

Date popover fields

ElementBehaviour
HeaderIssue key (click to open it in Jira), summary, a state badge, and a close button.
Header badge"Rolled up" for a parent, "Draft" when any field or dependency differs from Jira, "Synced" when it matches and the issue has a start date.
Start / DueCustom date pickers. An orange dot (#f97316) next to the label marks a field that differs from what Jira holds. Read-only text for a parent, with a "from KEY" chip naming the driving descendant.
DurationPositive whole numbers only — the input rejects anything that isn't a digit string greater than zero. Typing a duration recomputes the due date from the start; typing a start recomputes the due from the duration. Hidden for parents.
BufferNo / Yes toggle. Yes turns the bar amber and makes it a shock absorber for upstream slip. Hidden for parents.
Timeline summaryStart → Due Nd in monospace, once both dates are set, using short dates ("Apr 9"), not ISO.
Dependencies"Blocked by" and "Blocks" lists, rendered only when the issue has at least one link. A green dot means the link exists in Jira; an amber dot plus a "staged" tag means it doesn't yet. × removes a link (with a confirmation).
Chain →Opens the dependency chain viewer for this issue. Sits in the Dependencies header, so it only appears when the issue has dependencies.
ClearWipes start, due and duration (only for non-parents that have dates).
Cancel / CloseDismisses without applying. Labelled "Close" on a parent, which has nothing to apply.
ApplyDisabled until you change something. Applies your edits locally and triggers the cascade. This is NOT a Jira write — the plan toolbar's Apply does that. Not rendered for parents.
CarefulThe popover's own start+duration→due arithmetic calls addWorkingDays(getNextWorkingDay(start), duration) with NO working-day context, so it uses the DEFAULT Mon–Fri week with no holidays; it does not read the plan's calendar. On a Sun–Thu plan, or a plan with holidays, the due date the popover proposes can land on a non-working day. The drag path and the cascade engine both use the plan's real calendar.
NoteAny in-flight popover or chain viewer is force-closed the moment an Apply/Jira write starts, so the write-progress dialog is never stacked underneath them.

21Dependencies: drawing, deleting, and lag

How to create a finish-to-start link by dragging between two bars, what the live validation rejects and why, how to delete a link, and how to put a lag on one.

Creating a dependency

  1. 1Hover the predecessor's bar. A blue dot appears just off its RIGHT edge — the tail, meaning "this blocks…". Tooltip: "Drag to create dependency (this blocks...)".
  2. 2Press and drag from that dot. A rubber band follows your cursor: dashed blue while roaming.
  3. 3Every other eligible bar's LEFT edge grows a green dot (the head, "is blocked by…"), and while a draw is in flight the WHOLE target bar is a drop zone — you do not have to hit the 10px dot. Parent bars are not eligible targets.
  4. 4Hovering a candidate gives you the verdict BEFORE you drop. A green solid line and a green chip reading "FROM → TO" means it will be accepted; the target bar gets a green outline and glow. A red dashed line, a red chip and a red outline mean it will be rejected, and the chip names the reason.
  5. 5Release to stage the link. Press Escape, or release over empty space, to cancel.

Rejection reasons shown on the rubber band

Chip textMeaningToast on drop
Can't link an issue to itselfYou are over the source bar"Cannot link an issue to itself"
Already linkedThe link already exists"FROM already blocks TO" (warning)
Would create a cycleThe link would close a loop in the dependency graph"Cannot create link: FROM → TO would create a circular dependency"
NoteA new link is STAGED, not written. It appears immediately as a dashed blue arrow, the cascade re-settles the successor's dates straight away, and the actual Jira link is created when you press Apply. Deletions of existing Jira links are staged the same way, so a mis-click is reversible by discarding — there is no "delete writes immediately" asymmetry. Re-drawing a link whose deletion is staged simply cancels that deletion rather than queuing a delete+create pair.

Reading the arrows

Shape
A finish-to-start cable: it leaves the predecessor's RIGHT edge moving forward and enters the successor's LEFT edge from the left, as a soft cubic S-curve through the gutters between bars. The arrowhead therefore always points INTO the target bar, even for a backward or overlapping link (which becomes an honest C-loop rather than an arrow stabbing in from the right).
Curve reach
clamp(20, max(|dx| × 0.5, verticalDistance × 0.6) + fanOffset, 130) px. It widens with vertical distance so a pair of bars in the same columns but many rows apart sweeps sideways instead of collapsing into a vertical hairline.
Fanning
When several cables share a source or a target they would all emanate from one point. Each cable's exit is spread along the source bar's right edge and its entry along the target's left edge (step = min(7, 18/(n−1)) px, nearest target outermost so cables don't cross), and each sibling's belly is nudged 14px further sideways so they fan into distinct lanes.
Depth casing
Each cable is drawn over a canvas-coloured stroke 3px wider than itself, so where two cables cross the nearer one visibly breaks the further one — a metro-map over/under read instead of a dark smear. Critical edges use a blurred violet halo instead, because a canvas casing would erase their glow.
Colours
Grey solid #64748b = the link exists in Jira. Blue dashed #3b82f6 = staged, not yet written. Violet solid #7c3aed at 3px = a critical-path edge (both ends critical, Critical mode on). Blue #2563eb at 2.5px = in the currently highlighted chain — note a staged in-chain link stays DASHED, only its colour and weight change.
Hover
A CSS rule with !important wins over the inline hover colours, so hovering ANY arrow turns it light blue #60a5fa, thickens it to 3px and adds a blue drop-shadow, regardless of whether the link is real, staged or critical.

Deleting a dependency

  1. 1Click anywhere on the arrow. (Hovering it first shows a tooltip: "FROM blocks TO · staged — click to remove".)
  2. 2A small chip opens at the cursor showing FROM blocks TO, with a "staged" badge if the link isn't in Jira yet.
  3. 3Click "Remove link". A confirmation dialog appears: title "Remove Dependency", body "Remove: FROM blocks TO?", confirm button "Remove".
  4. 4The chip closes on Escape or an outside click.

The same removal is available in two other places, and all three ask for the identical confirmation: the × next to each entry in the date popover's Blocked by / Blocks lists, and the trash button on each relationship row in the chain viewer. After a removal the app also toasts which issue is now free — either "Unlinked FROM → TO. TO is now unconstrained." or "Unlinked FROM → TO. TO still has N other predecessors." — and re-settles the freed successor, not the predecessor.

Setting a lag

Where
The same chip you get by clicking an arrow. Below the FROM/TO line it shows "Lag (working days)" with a − / value / + stepper.
Range
Whole working days, minimum 0, no upper bound. The − button is disabled at 0. A non-zero lag renders the number in the primary colour.
Effect
The lag is stored on the SUCCESSOR (predecessorLags[predecessorKey]) and the whole plan is immediately re-settled through the same pure engine used elsewhere, so you see the resulting date shifts live. Those shifts then flow through the normal staged-Apply diff.
Persistence
The lag is written to plan storage right away via the setLinkLag resolver. If that write fails, only that one edge's lag is reverted (and only if your value is still the current one, so a rapid re-drag is never clobbered), the plan is re-cascaded, and an error toast says so.
Meaning in the schedule
A successor's required start is the next working day strictly after the predecessor's due date, advanced by the lag in working days. With multiple predecessors the engine takes the maximum over all of them.

Seeing a whole chain

Hover a bar
After 160ms, the entire connected dependency component containing that issue (all transitive predecessors AND successors) lights up — bars get a blue ring and glow, in-chain arrows go blue and thicken — and everything else dims to 25% opacity. Isolated issues with no links never trigger it, and the delay stops quick scans from flashing.
Chain viewer
From the date popover's "Chain →". A modal titled "Dependency Chain" listing the chain flat, walked from its roots down through successors, with a depth number on every row below the root, a A blocks B connector line between rows, each issue's dates and duration, a Buffer/Set Buffer toggle, an orange dot for staged rows, and a trash button per relationship. The header reports "N issues in chain", plus "· M levels deep" when the chain is more than one level.
Show on Gantt
A button in the chain viewer header (only when the chain has more than one issue). It closes the modal, highlights that chain on the timeline and auto-clears after 4.5 seconds.
When it clears
On mouse-out, after the 4.5s timer, or on any bar-drag date edit — a stale glow after positions have shifted would mislead.
LimitPassive hover-chain highlighting is disabled while Critical mode is on: the violet critical emphasis owns the chart and the two dimming systems would fight. The chain viewer's explicit "Show on Gantt" still works.

22The complete bar-state legend: fill and outline are two separate axes

A bar's FILL says what the issue is; its OUTLINE says how it relates to Jira. Read them independently — the combinations are meaningful.

TipThis is the single most important thing to know about reading the timeline. FILL = what the issue IS (parent, buffer, exhausted buffer, done, normal). OUTLINE = how it RELATES TO JIRA (draft, cascaded, critical, synced). They are independent. A buffer with an unsaved edit is an amber bar with an amber outline. A completed task you have dragged is a green bar with an amber outline. These used to be one chain of conditions with "matches Jira" tested first — which meant that on a freshly indexed plan every issue matched, that branch caught everything, and the Buffer, Exhausted and Done fills were literally unreachable until someone edited the plan.

FILL — what the issue is (first match wins, top to bottom)

FillConditionAppearance
ParentThe issue has at least one child that is also in this planSlate gradient #475569 → #334155, drawn as a 14px bracket rather than a solid bar
Bufferbuffer === 'Yes' and not exhaustedAmber gradient #d97706 → #f59e0b with an amber glow
Exhausted bufferbuffer === 'Yes' AND Number(duration) === 1 AND startDate === dueDate — the buffer has been squeezed down to a single dayViolet gradient #7c3aed → #a855f7, violet glow, and a continuous 2s pulse animation
DonestatusCategory === 'done'Green→blue gradient #16a34a → #60a5fa at 50% opacity
NormalEverything elseBlue gradient #2563eb → #3b82f6; adds a blue glow when the issue is synced and has a start date
CarefulOrder matters. A buffer that is also Done renders AMBER, not green — buffer is tested before status. A parent beats everything: a parent that is a buffer still renders as a slate bracket.

OUTLINE — how it relates to Jira (applied in this order; later wins)

OutlineConditionAppearance
SyncedNone of the belowNo outline at all
DraftCurrent start/due differ from the applied baseline, OR the duration was edited (only counted when BOTH the original and current duration are set), OR the issue had no applied dates and a staged edit gave it some2px SOLID amber #d97706 (outline-offset −2px, so inset — never a left rail)
CascadedThe issue is in the set the last edit moved. Lasts 800ms after each cascade.2px DASHED blue #60a5fa plus a blue glow, with a staggered slide animation
CriticalCritical mode is on AND the issue is on the zero-slack chain2px SOLID violet #7c3aed plus a violet glow

Because they are applied in that order, a critical bar that is also cascaded and drafted shows the violet critical outline. Parents never take any of these outlines, and neither does a milestone's bar body. While you are drawing a dependency the drop-target outline overrides all of them: a valid target takes a green 2px outline offset OUTWARD (and an invalid one a red outline), on milestones too.

Why the duration rule is written that way

A duration-only edit counts as a draft because shortening a BUFFER is the everyday case: a buffer absorbs slip by holding its due date and shrinking, so both dates still match what Jira has and the bar would otherwise keep reporting itself as synced while carrying an unapplied change. The comparison only runs when both sides have a duration, because a null the engine later filled in is the app deriving a span, not a user edit — treating that as a draft would outline every duration-less issue forever.

The Legend popover (9 items)

LabelDescription in the UI
SyncedMatches Jira
DraftUnsaved local edit
CascadedMoved by the chain
BufferAbsorbs slip
ExhaustedBuffer ran out
DoneCompleted
CriticalZero-slack chain
ParentRolled-up bracket
MilestoneZero-duration marker

Other marks on a bar

Progress line
A dark 3px line along the bottom of the bar, MS-Project style, derived from status category: 100% for done, 50% for in-progress (indeterminate), nothing for to-do. Not drawn on parents or milestones.
Bar tooltip
Hovering shows KEY: summary, then start → due with (Nd) appended when a duration is set (a missing date prints as ?), then either "✓ Synced" or "✎ Draft", then "Drag to move • Drag edges to resize".
Cascade ripple
When an edit moves several bars, each moved bar's slide is delayed by its depth within the cascaded set: min(depth, 12) × 45ms, so up to 540ms. The schedule visibly ripples in dependency order rather than snapping all at once.

23Milestones, parent brackets, guide lines and baseline ghosts

The four overlay layers drawn on top of the bars: milestone diamonds, plan target markers along the top, parent brackets with their rollup guides, and the baseline ghost bars.

Milestone diamonds (issues)

A milestone issue is drawn as a 13px rotated square in the middle of its row instead of a bar — violet #7c3aed, or green #16a34a when the issue is done. The bar body behind it is transparent with no state outline and no shadow. Because a milestone is by definition a single day, its bar is only one column wide, which is always below the 50px label threshold — so the issue key is always printed just outside it.

What counts as a milestone issue

The rule
It must be a single point in time (startDate === dueDate, and a start date must exist) AND either have an explicitly declared zero duration (duration != null && Number(duration) === 0), or have a Jira issue type of "milestone".
Never a milestone
Parents (their dates roll up) and buffers (an exhausted buffer is its own violet state).
Why it is written that way
The rule used to be "start === due AND duration ≤ 1", which turned every one-day task into an unlabelled diamond and — worse — turned ordinary tasks into diamonds mid-drag, because a cascade that collapses a task to one day also collapses due onto start. A milestone is now something you DECLARE, not something a schedule change can accidentally create. A milestone-typed issue that spans real days is drawn as the bar it is.
One definition, one file
There used to be three copies of this test and they disagreed (GanttChart used duration <= 1, cascade-provenance used Number(duration || 0) <= 1 so an undefined duration read as 0, and the harness had no parent guard), so the Explain dialog could call something a milestone that the chart drew as a bar. It now lives in utils/milestone.js alone.

Target markers (plan milestones)

What they are
The named target dates defined in the plan's own metadata, not issues. They are drawn as a separate overlay across the full height of the chart at zIndex 4.
How they look
A 2px violet (#7C3AED) vertical guide line down the whole chart at 50% opacity, a small ◆ at the very top anchored on the date, and a solid violet pill carrying the milestone's name next to it.
Hover
Hovering the name pill shows name · YYYY-MM-DD.
Where they land
At dateToX(date) + pxPerDay/2 — the centre of that day's column, so the line runs through the middle of the day, matching the today line.

Parent brackets

Shape
A 14px-tall bracket, not a filled bar: a 5px slate top rail with a downward foot at each end (8 × 10px, 3px strokes).
Span
Recomputed at render time from descendants — earliest start and latest due across the whole subtree, including grandchildren. It always spans its children even on first load before any edit, regardless of what the backend stored on the parent itself.
Label
The issue key in small uppercase above the bracket — inside at the left when the bracket is wider than 60px, otherwise just past its right edge.
Clicking one
Opens the read-only rollup popover with a "Rolled up" badge, the hint "Dates roll up from this issue's children. Edit a child to change them.", and a "from KEY" chip under each date naming the exact descendant that supplies it (clicking the chip opens that issue in Jira). It has no Apply, no Clear, and no duration or buffer fields — only Close.

Rollup guide lines

What they show
Two thin dashed verticals dropping from the parent bracket's left and right ends to the specific descendants supplying the earliest start and the latest due — so you can see at a glance what is driving each end of the rollup.
Appearance
1px, dash pattern 2,3, slate at 35% opacity, ending in a small horizontal tick just below the target bar.
When they are drawn
Only when the driving descendant is currently visible BELOW the parent in the row order (a collapsed or auto-arranged-above descendant gets no guide), and on very large plans only when the guide's row span intersects the rendered window.
Turning them off
The parentGuides display preference (default on), in the admin Display settings.

Baseline ghosts and the WAS HERE marker

What they are
A hollow 1px-dashed rectangle at each issue's FROZEN baseline position, drawn behind the live bar at 70% opacity. A rightward gap between the ghost's end and the live bar's start is schedule slip, visible at a glance.
When they appear
Only for leaf issues (never parents), only when a baseline snapshot exists for the plan, and only when the issue has actually MOVED from its baseline — identical positions would add pure noise.
The WAS HERE label
Ghosts wider than 58px carry a small uppercase "was here" caption centred in the outline. Hovering any ghost gives the native tooltip "Baseline — KEY was scheduled 2026-03-02 → 2026-03-13".
Why the label exists
The ghost used to be an unlabelled transparent dashed rectangle with pointer-events off, so its tooltip was unreachable and it read as "the item moved and left an empty rectangle behind" with no way to find out otherwise.
Interaction
Ghosts are hoverable but never draggable — the live bar owns dragging. They are display-only and never touch the cascade.
Toggle
The "Baseline" button in the Gantt control strip, which only exists when the plan has a baseline. Default on, remembered across sessions.

24Critical path, auto-arrange, and the reorder suggestion

The Critical toggle and how the zero-slack chain is computed, what auto-arrange does to row order, manual drag-reordering, and the banner that offers to persist a tidier order to Jira rank.

The Critical toggle

Tooltip
"Highlight the critical path — the zero-slack chain that drives the finish date".
What turns on
Critical bars take a violet outline and glow; critical arrows go violet, thicken to 3px and gain a soft blurred violet halo; every non-critical bar dims to 40% opacity and every non-critical arrow to 20%. The button label becomes "Critical (N)".
How the path is computed
A read-only CPM pass over LEAF issues only (parents are excluded because their dates are rollups). Forward pass: earliest finish = the longest predecessor chain by duration. Backward pass: latest finish, seeded from the project end for any issue with no successors. An issue is critical when its latest finish equals its earliest finish — zero slack.
Task length used
The explicit duration when it is set and positive; otherwise the issue's date span in days (due − start + 1); otherwise 1. Without that date-span fallback every issue scheduled by dates alone would count as 1 day and the "critical path" would degenerate to whichever single node is deepest instead of the real longest chain.
Cycles
Both passes carry a cycle guard, so a circular dependency cannot hang the computation.

Auto-arrange

Default
ON, remembered across sessions (ppm.display.gantt.autoArrange).
What it does
Orders rows by a dependency-tidy topological sort — the same algorithm the reorder banner uses — then refines within families so a leaf that blocks something far away is pulled toward its family's near edge. Dependency arrows therefore point forward and stay short.
What it does NOT do
It never touches Jira rank, and the Table view is unaffected. It is a view-only preview of the tidy order; the banner is what persists it.
Hierarchy
Always preserved. Families stay grouped under their parents at every level, and root order is fixed by the base order.
Refinement guarantee
The within-family pass runs up to four barycentre sweeps scored as (backward arrows, then total arrow length). A sweep is kept only when it strictly reduces backward arrows, or matches them and cuts total length — so the base order's backward-arrow guarantee can never be traded away.
Turning it off
Gives you raw Jira rank order and enables manual drag-reordering.

Reordering rows by hand (auto-arrange off)

  1. 1Press and drag a row's SIDEBAR cell (the label side). The dragged row fades to 50% and the cursor becomes grabbing.
  2. 2Move over another row; the drop target gets a filled highlight and a 2px top border.
  3. 3Release. Dropping below the origin stages rankAfter: target; dropping above stages rankBefore: target. A toast confirms "KEY reordered", and the whole family (the issue plus all its descendants) moves together.
  4. 4The moved row flashes for about a second (1100ms), and keeps an amber row tint plus a "Moved" chip until you apply or discard the rank change.
  5. 5Dropping an issue onto one of its own descendants is refused with the toast "Cannot move X into its own descendant Y" — otherwise the move would silently do nothing while still registering a phantom rank operation.

The reorder suggestion banner

When it appears
Only when auto-arrange is OFF, the algorithm has found a reorder that MEASURABLY beats the current order (scored before vs after), and you have not already dismissed that exact suggestion for this plan.
What counts as worth flagging
A dependency is flagged when it stretches more than 6 rows OR points BACKWARD. The 6-row threshold applies only to long forward arrows — a backward arrow counts at ANY gap, including two adjacent rows, because an arrow pointing the wrong way is the worst readability fault.
Why auto-arrange suppresses it
With auto-arrange on, the view already shows the tidy order, so the banner would be nagging about an underlying rank you are not looking at.
Headline
"Reorder N issues to tidy dependencies", followed by "This <win>. Families stay grouped under their parents and other plans aren't affected."
The claimed win
Reported from measured before/after scores, never asserted: "straightens N backward arrows", "brings N long arrows into view", or "tightens the chain by N rows" (the last only when neither of the first two applies).
The caveat
Shown whenever there is anything to disclose: if the reorder lengthens forward arrows in exchange it says so ("It lengthens N forward arrows in exchange"); otherwise, if long arrows remain, it says how many still remain. When neither applies — a clean win with nothing traded away and no long arrows left — no caveat appears. Two-sided disclosure by design — the banner never oversells.
Buttons
Preview/Hide, Dismiss, Apply reorder.
Preview
Lists up to 8 operations as KEY → after/before OTHERKEY, with a "+N more" line.
Apply reorder
Sorts the local issue order to exactly the verified new order and stages every rank operation for Jira, then toasts "Reordered N issues to tidy dependencies" and dismisses the suggestion. There is no imperative per-op family walk that could drift on a partial failure — the view and the staged writes agree by construction.
Dismiss
Remembered per plan in localStorage (ppm.ranksuggest.dismissed.<planId>), keyed by an order-independent fingerprint of the operation set — so the same suggestion stays dismissed, but a genuinely different one will reappear.
First-load toast
The first time a plan loads with a fixable arrow you also get a warning toast lasting 6 seconds: "A tidier row order would straighten N backward arrows — review it above the Gantt", or "…would shorten N long arrows…" when there are no backward ones. It fires at most once per plan per session, and only when there is a concrete win to advertise.

25The Cascade Impact strip and the Explain dialog

What each figure in the blast-radius strip means, where the numbers come from, and how to read the Explain dialog's "can't begin until X finishes DATE" lines.

Every date edit ripples. The Cascade Impact strip appears directly above the timeline after an edit and quantifies the ripple the engine just computed. It is strictly READ-ONLY: it never recalculates or writes anything, it only narrates what the cascade already did, so you can see the blast radius before committing.

The strip's figures

FigureReads asMeaning
Moved count"This change moved 7 issues"How many OTHER issues the cascade repositioned. The issue you edited is always excluded, as is anything whose start and due both came back unchanged.
Finish slip"+4d finish slip"Working days the PLAN'S FINISH DATE (the latest due date across the whole plan) moved out.
Finish pulled in"-3d finish pulled in"The same figure when negative — your edit brought the plan's end date forward.
Buffers absorbed"2 buffers absorbed"Buffers that shrank to soak up upstream slip without disappearing. Disjoint from Exhausted: a dead buffer is not also counted as absorbed.
Exhausteda violet "1 exhausted" pillBuffers squeezed to a single day — no slack left on that path.

The strip's buttons

ButtonWhat it does
✦ ExplainOpens the Explain dialog. Only offered when the strip is showing single-edit provenance.
Details / HideExpands a scrollable list of the moved issues: clickable key, summary, and either "+Nd"/"-Nd", "absorbed", or "buffer exhausted". After a single edit this is the same capped set the Explain dialog shows — the 40 largest moves — so on a very large ripple the headline count exceeds the rows listed; only the full-recompute fallback lists every moved issue.
×Dismisses this specific result. It will reappear on the next cascade (dismissal is keyed to the exact set of moves).
CarefulThere are two number sources and they measure differently. After a single edit the strip renders the engine's provenance, where every delta is in WORKING days and the headline is the plan's real finish slip. After a full recompute — triggered when you change the plan's working-day schedule or holidays — there is no single-edit story, so it falls back to a current-vs-Jira comparison in CALENDAR days, the label changes from "finish slip" to "downstream slip" (it is the SUM of every positive slip, not the plan finish), and the Explain button is not offered.

The Explain dialog

Title
"✦ Explain this change" with the subtitle "Exactly what your edit did — computed from the schedule engine, not guessed."
Nothing is generated
Every date, day-count, issue key and cause is a diff of the pre/post cascade or a recomputation of the binding predecessor on the settled map. There is no LLM in this dialog, so a wrong fact is structurally impossible.
Your edit
A plain-English restatement in one of four shapes depending on which field you touched — "You moved WFH-12's start to Mar 4 (was Mar 2 → Mar 10)", "…moved WFH-12's due date to…", "You set WFH-12 to 7d", or "You rescheduled WFH-12 to Mar 4 → Mar 12" — plus a note if your chosen date was snapped to the next working day, and the violation text if the edit was blocked.
Headline totals
Issues moved, plan finish delta with before → after dates, buffers absorbed, buffers exhausted, milestones slipped later, and milestones pulled earlier. Each is omitted when it is zero.
Per-issue rows
Key, a ◆ if it is a milestone, summary, and either "buffer exhausted", "absorbed Nd", or the signed day delta — then a WHY line underneath. If nothing else moved it says "This edit didn't move any other issues."
Cap
At most 40 rows. When there are more it says "Showing the 40 largest moves." The TOTALS are always computed over the full set, before the cap.
Footer
"These facts are computed directly from the engine's cascade — nothing here changes your plan."

Reading the WHY line

"now starts Mar 12 — can't begin until WFH-8 finishes Mar 11"
This issue's start is BOUND by that one named predecessor. It is stated as a final-state constraint, not as a chain of causation: on the settled schedule, WFH-8's due date is what forces this start.
"+ 3d lag"
Appended when that link carries a lag. The required start is the next working day after the predecessor's due date, advanced by the lag in working days.
"bound by its predecessors"
The fallback, used whenever a single driver cannot be named with certainty. It is always true and can never be wrong.
When a name is withheld
The check falls CLOSED. A predecessor is named only when (a) exactly one predecessor is binding — no tie, (b) no predecessor finishes LATER than the binding one (possible when lags differ, and "latest finisher" would then be a false label), and (c) the issue's actual start equals the computed required start, so it is a genuine fixed point and not a stale or cyclic node.
"rolled up from its children"
Used for parent rows. Parents are never assigned a driver because their dates are rollups, not constraints.

The "what this means" advisory panel

SituationSeverity / headlineColour
Your edit was rejected by a dependency and nothing movedblocked — "Edit blocked"Violet
Your edit was rejected but part of the plan shifted anywayblocked — "Edit blocked" (partial)Violet
A committed milestone moved lateralert — "Milestone slipped"Red
Plan finish slipped 10 working days or morealert — "Finish slipped"Red
A buffer was spent to zerocaution — "Buffer spent"Orange/amber
Plan finish slipped 1–9 dayscaution — "Finish slipped"Orange/amber
A buffer absorbed the hit and dates heldnotice — "Buffer absorbed it"Green
A milestone moved earliernotice — "Milestone earlier"Green
Plan finish pulled innotice — "Finish pulled in"Green
15+ issues moved but nothing committed changedcalm — "Contained"Muted
A few dates shifted, everything committed heldcalm — "Contained"Muted
Nothing moved at allpanel suppressed entirely—

Severity — which owns the panel's colour and headline — is bound to the DATA, never to the prose, so the wording can never escalate or de-escalate the chrome. The first matching row wins and supplies the primary message; additional co-occurring damage (a finish blowout, a buffer that also hit zero, a truncated list) is appended as extra "Consider" lines rather than being dropped. The wide-ripple case has two wordings: when parents outnumber real cascaded issues it says you moved a summary and its rows came with it, rather than calling it a dependency ripple.

NoteThe Cascade Impact strip appears above the Table view too, with the same figures and the same Explain button — table edits cascade identically.

26Rows, hierarchy, and what happens on very large plans

Expand/collapse and what it persists, the sidebar row anatomy, and exactly what changes above 150 rows.

The sidebar row

ElementBehaviour
ChevronOnly on issues that have children in this plan. Rotates 90° when expanded. Click to expand/collapse.
Type badgeThe first letter of the issue type ("?" if it has none). Violet gradient for sub-task-level items (hierarchy level below 0), blue for everything else.
Issue keyClick to open the issue in Jira's modal. Dotted underline.
SummaryClick to select the row (which tints it blue).
Orphan chip"↑ PARENTKEY" on a solid slate pill. Means the issue HAS a parent in Jira but that parent is not in this plan, so it renders at the top level. Clicking opens the parent. The tooltip tells you to turn on "Include parents" in the plan's sources to pull it in.
Moved chipAn amber "Moved" chip plus an amber row tint. Persists until the pending rank change is applied or discarded.
Status pillThe Jira status, coloured by category: green for done, blue for in-progress, grey for to-do.

Expand / collapse

Default
All parents expanded on a plan you have never opened. A parent that appears later (after a re-index) is added as expanded.
Persistence
Per plan, in localStorage under ppm.display.expanded.<planId>. Collapses you made survive a reload — on the first sync with a stored set, parents you had collapsed are not auto-re-expanded.
Shared with the Table
The same expansion state is owned by PlanView and drives both the Gantt and the Table view.
Animation
Newly revealed rows fade and slide in over 0.24s, and the parent row you toggled gets a brief blue highlight pulse (0.5s, cleared after 600ms).
What collapsing does NOT do
Hidden children remain part of the plan and continue to cascade and roll up. Only the row count in "Showing X of Y" changes.

Large plans — what changes above 150 rows

AspectBelow 150 visible rowsAbove 150
Row renderingEvery row is in the DOMOnly the rows in the viewport, plus 8 above and 8 below, are mounted; spacer divs hold the scroll height
Dependency arrowsAll drawnAn arrow is skipped when its whole row span sits outside the window; long arrows that PASS THROUGH the window are kept
Parent guide linesAll drawnSkipped when the guide's span is outside the window
Milestone markersUnchangedUnchanged — the milestone overlay is an absolutely positioned full-height layer that draws by date and row-index maths, so it is unaffected by which row divs are mounted. The arrow SVG is positioned the same way, but individual arrows are culled as described above — only the layer's geometry is immune to virtualization, not every edge in it
Today lineOne segment per rowAlso per row, so it exists only on mounted rows — invisible in practice, since unmounted rows are off-screen anyway

The threshold exists because a 5000-issue plan put roughly 95,000 row nodes in the DOM, which measurably killed scroll performance. The window is recomputed at most once per animation frame during a fast scroll, and also on mount, on row-set changes and on container resize.

NoteTwo costs on this chart track the plan's DATE SPAN, not its issue count. The day header is one element per day across the padded range — measured at 2,191 columns on a plan spanning 2023–2026, and 1,461 on the 5,300-issue perf plan — so a long-running plan is heavier than a big one. The header is memoized separately for exactly this reason, and weekend shading is one repeating CSS gradient per row rather than the ~300 divs per row it used to take over a three-year timeline.
LimitThe header's height is MEASURED with a ResizeObserver, not assumed. It is 50px, not the 48 you might expect: the sidebar half is 48 and the timeline half is two 24px rows plus a 2px bottom border, and the flex row takes the taller child. That 2px was the origin for the arrow overlay, the milestone overlay and the virtualization row maths, so every connector and milestone line used to sit 2px above the bars it pointed at. If you are building on this, measure rather than hardcode.
CarefulBars whose dates are malformed do not crash the chart. An invalid date degrades to null rather than throwing a RangeError out of toISOString(), which would previously blank the ENTIRE timeline for one bad issue, and the bar clamps to its 6px minimum. An issue whose start is after its due date is a real state you will see rendered as that 6px stub.

27The Dashboard: what it is and where the numbers come from

How to reach the plan health dashboard, what data it reads, the guarantee that it never changes a schedule, and the six KPI tiles with their exact values.

Open a plan and click the Dashboard tab in the plan toolbar (the tabs are Gantt, Table, Dashboard, Schedule, Permissions). The chosen tab is remembered per plan in localStorage under ppm.display.viewMode.{planId} — but only for Gantt, Table and Dashboard; Schedule and Permissions are deliberately not persisted, so you never come back to a plan sitting on a config screen. There is also a command-palette entry, "Go to Dashboard". The dashboard only renders when the plan has indexed issues; with zero issues you get the "No issues indexed" empty state ("Index this plan to fetch issues from your sources.") and an "Index Now" button instead.

Everything is computed in the browser, from the loaded plan

The dashboard receives the same in-memory issues array the Gantt and the Table use, and derives every figure client-side with three pure functions imported from plan-metrics.js: computePlanMetrics, computeWorkload and computeRiskScores (computePlanMetrics in turn calls computeBufferHealth from the same file). The file's own header states the contract: "ZERO schedule mutation: nothing here writes startDate / dueDate / duration / buffer / predecessors / successors, and nothing calls the cascade engine." Nothing on this screen edits your plan or your Jira issues. The two exceptions are the explicit Set baseline / Clear buttons, which write a separate baseline record (see the baseline section).

NoteBecause it reads the in-memory plan, the dashboard reflects your UNSAVED staged edits. If you have dragged bars in the Gantt but not applied to Jira, the health score already reflects the new dates. It is a picture of the plan as you have it on screen, not of Jira.

Leaf tasks vs parents — the single most important distinction

Most metrics are computed over LEAVES, not over all issues. A leaf is any issue that no other in-plan issue names as its parentKey. The code deliberately does NOT use "has an empty children[] array", because children[] holds only Jira SUBTASKS: an Epic whose Stories are linked by parentKey would then count as a leaf and be double-counted alongside its own children in progress, risk, workload and the critical path. The plan toolbar shows both figures side by side, e.g. "45 issues · 38 tasks" (taskCount = issue count minus parent count), exactly because counting only tasks used to read as a bug. The "· N tasks" half is hidden when the plan has no parents at all, i.e. when the two numbers are equal.

The six KPI tiles

TileValue shownSub-lineClickable?
Issuesm.total — every issue in the plan, parents included"N tasks" where N = leafCountNo
CompletepercentComplete, with a % suffix and a progress meter bar whose width is that same percentagenone (has an info tooltip)No
On trackonTrack = (open leaves) − overdue − atRisk, floored at 0noneYes, when > 0
At riskatRisk count (value ink drops to muted when 0)noneYes, when > 0
Overdueoverdue count (value ink drops to muted when 0)noneYes, when > 0
Buffersbuffers.total — every issue with Buffer = Yes"N exhausted" (accent turns violet) or "all healthy"No

Drill-down

Clicking the On track, At risk or Overdue tile (or pressing Enter/Space on it — the clickable tiles are keyboard-focusable buttons with the title "Show {label} issues") opens a panel below the tiles titled "On-track issues", "At-risk issues" or "Overdue issues", with the hint "N issues · click to open". Each row shows the issue key, its summary and its due date (or an em dash). Clicking a row — or pressing Enter/Space on it — opens that issue in Jira's native issue modal (ViewIssueModal from @forge/jira-bridge; outside Forge it is a no-op). Clicking the same tile again, or the ✕, closes the panel. The keys listed come from the buckets object computed alongside the counts, so the list and the tile number can never disagree.

The hero band

At the top sits the plan health ring (see next section) with three lines: the label "Plan health" and its breakdown tooltip; the state word, one of Healthy / At risk / Critical; and a subtitle reading "{onTrack} on track · {atRisk} at risk · {overdue} overdue", to which " · N buffers exhausted" (singular "buffer" for one) is appended when at least one buffer is exhausted. The score inside the ring counts up on mount and the arc draws itself in over ~0.9s whenever the score changes.

LimitSections hide themselves when empty. Schedule risk only renders when there is at least one red or amber issue. Resource workload only renders when at least one assignee qualifies. Milestones only render when the plan contains at least one declared milestone. Baseline & variance only renders when the plan has an id, and its variance body only when at least one issue has moved. A missing card means "nothing to show", not a failure.

28The plan health score and its exact weighting

The composite 0-100 score in the hero ring: its four components, their formulas and weights, the colour bands, and why it can never reach 0.

The formula, exactly as implemented

leaves        = max(1, leafCount)
onTime        = 1 − overdue / leaves
bufferHealth  = buffers.total > 0 ? buffers.avgHealth : 1
lowRisk       = risk.scored.length > 0 ? 1 − red / max(1, risk.scored.length) : 1
progress      = 0.5 + 0.5 × (percentComplete / 100)

raw   = 100 × (0.35×onTime + 0.25×bufferHealth + 0.25×lowRisk + 0.15×progress)
score = clamp(round(raw), 0, 100)

The four components

ComponentWeightFormulaReaches 1.0 when
On-time35%1 − overdue ÷ leaf countNo leaf task is past its due date
Buffer health25%the average health of all buffer issues (1 when the plan has no buffers)Every buffer is at its original, uncompressed length
Low risk25%1 − (red-band issues ÷ risk-scored issues); 1 when nothing is scoredNo leaf scores 60 or above on schedule risk
Progress15%0.5 + 0.5 × (percent complete ÷ 100)The plan is 100% complete (effort-weighted)

Bands, colour and wording

ScoreState word in the heroRing colour token
75 – 100Healthy--lz-success (green)
50 – 74At risk--lz-buffer (amber)
0 – 49Critical--lz-error (red)

Seeing the breakdown

The info icon next to "Plan health" shows the tooltip "Weighted score:" followed by one line per component in the form "On-time 87% × 35%", "Buffer health 100% × 25%", "Low risk 92% × 25%", "Progress 70% × 15%" (each percentage is the component rounded to a whole number). The comment in the code says why it exists: the score was previously an opaque number. The raw, unrounded component values are also exposed on the hero element as the data attributes data-ontime, data-buffer, data-lowrisk and data-progress, alongside data-score and data-testid="plan-health", which is what the automated tests assert against.

CarefulThe Progress component has a floor of 0.5, not 0 — a plan with 0% complete still scores 50% on that component. Consequence: the lowest score the formula can ever produce is 100 × 0.15 × 0.5 = 7.5, which rounds to 8, and only when all three other components sit at 0 (every leaf overdue, every buffer exhausted, every scored leaf red). A health score of 0 is unreachable. Read "8" as the bottom of the scale, not as a bug.
CarefulAn all-green plan scores 100 minus nothing, because buffer health and low risk both DEFAULT to 1 when there is nothing to measure. An empty plan tops out at 93, not 100: with nothing done, percent complete is 0 and the Progress component sits at its 0.5 floor, so raw = 100 × (0.35 + 0.25 + 0.25 + 0.15 × 0.5) = 92.5, rounded to 93. (A plan with no issues at all never shows the dashboard — it shows the "No issues indexed" empty state instead.) A plan with no buffers at all is credited a full 25% for buffer health, and a plan with no due dates anywhere is credited a full 35% for on-time. A high score on a thin plan means "nothing measurable is wrong", not "this plan is well built".
NoteOverdue is the only failure signal weighted into On-time. At-risk issues do NOT reduce the health score directly — they only influence it indirectly through the Low risk component, where each at-risk leaf adds 15 points of risk score and only pushes the band to red at 60.

29Complete % — effort-weighted, and why it disagrees with the Done count

The exact effort weighting behind the Complete tile, the status-category proxy table, and how the status breakdown donut counts differently on purpose.

Percent complete

for each LEAF issue i:
    dur_i = max(1, Number(i.duration) || 1)      // working days; missing/0 → 1
    pct_i = { new: 0, indeterminate: 50, done: 100 }[i.statusCategory] ?? 0

percentComplete = round( Σ(pct_i × dur_i) / Σ(dur_i) )     // 0 when the denominator is 0

Status-category → percent-complete proxy

Jira status categoryCategory keyCredit given
To Donew0%
In Progressindeterminate50%
Donedone100%
anything else / missingthe indexer writes the literal string 'undefined' when Jira sends no category0% (the ?? 0 fallback)

Why this legitimately differs from the raw Done count

The Complete tile carries an info tooltip that states the reason verbatim: "Effort-weighted progress: each task counts by its working-day duration — done 100%, in progress 50%, not started 0%. This is why it differs from the raw done-issue count in the status breakdown below (which credits nothing for in-progress work)." Two effects stack. First, a 20-day task that is done contributes twenty times as much as a 1-day task that is done, so completing large work moves the number more. Second, in-progress work earns half credit here and zero credit in the donut. Take twelve one-day leaves, three done, six in progress, three not started: the donut reports 3 of 12 done, while the tile reads (3×100 + 6×50) ÷ 12 = 50%.

CarefulComplete % is computed over LEAVES only; the Status breakdown donut is computed over ALL issues, parents included. So the donut's Done segment can include epics that Jira has resolved while the tile ignores them entirely. The two are answering different questions and are not meant to reconcile.
LimitThere is no real percent-complete field. The code calls PCT_BY_CATEGORY a "proxy (used when no explicit % field exists)". An issue that is 90% finished but still In Progress counts as exactly 50%, and there is no way to tell the app otherwise.

Status breakdown donut segments

Legend labelCounts issues whose statusCategory isColour token
Donedone--lz-success
In progressindeterminate--lz-buffer
To donew--lz-text-muted
Otheranything else (the indexer writes the string 'undefined' when Jira sends no category)--lz-border-strong

Segments with a value of zero are dropped from both the ring and the legend, and each legend entry shows its own count in bold. The number in the centre of the ring is m.total — every issue in the plan — with the caption "issues". It is rendered as a bare number with no thousands separators (a 5300-issue plan reads 5300). Thousands grouping applies to the KPI tile values and to the health-score ring, not to the donut centre.

30On track, at risk and overdue — the precise test

The exact classification rule for open leaf tasks, including how 'today' is fixed, what happens to undated issues, and why done work never appears.

Classification, run over LEAF issues only

today = UTC midnight of the current UTC date (Date.UTC of getUTCFullYear/Month/Date)
due   = new Date(i.dueDate + 'T00:00:00Z')

for each LEAF i:
    if i.statusCategory === 'done'      → skipped entirely (no bucket)
    if no dueDate                        → ON TRACK
    d = round((due − today) / 86,400,000)      // whole CALENDAR days
    if d < 0                             → OVERDUE
    else if d <= 5                       → AT RISK
    else                                 → ON TRACK

onTrack (tile value) = max(0, openLeafCount − overdue − atRisk)

The three states at a glance

StateTestTile colourIncluded in health score
Overdueopen leaf, has a due date, due date is strictly before today--lz-error (red)Yes — the only input to the 35% On-time component
At riskopen leaf, due in 0 to 5 calendar days inclusive (today counts as at risk)--lz-buffer (amber)Only indirectly, via +15 on its schedule-risk score
On trackopen leaf that is neither, INCLUDING every open leaf with no due date--lz-success (green)Only as the residual
CarefulThe five-day at-risk window is CALENDAR days, not working days. A task due next Monday is at risk on the preceding Wednesday, and a task due on Saturday is "at risk" through the weekend even though nobody works it. The rest of the app (durations, cascade) is working-day based; this one threshold is not.
CarefulAn open leaf with NO due date is counted as ON TRACK. On a half-scheduled plan the On track tile is therefore flattering — it is counting unscheduled work as healthy. Cross-check with the Table view by sorting on Due Date (unset values always sort last) to see how many rows have no date at all.
NoteBoth "today" and every due date are read in UTC, so the day boundary is UTC midnight, not your local midnight. Late in the evening in the Americas the dashboard has already rolled over to the next day; early morning east of UTC it has not yet.
NoteParents never appear in any of the three buckets. Their dates roll up from their children, so an epic cannot be overdue independently of its tasks; the tasks that made it late are the ones listed. Done leaves are excluded outright, so a task closed three months after its due date does not show as overdue.

Drill-down consistency

The counts and the drill-down list are produced in the same loop: the buckets object stores the actual issue keys per state so, in the code's words, "the dashboard KPI tiles can drill into the matching issues". Because every open leaf lands in exactly one bucket, the three tiles always sum to the number of open leaves.

31Schedule risk — the cascade-derived RAG score

The per-issue 0-100 risk score: its four weighted inputs, the band thresholds, what the RAG bar and top-6 list show, and when the card appears at all.

The card is headed "Schedule risk" with the hint "cascade-derived · slip · chain depth · deadline · buffer depletion". Every leaf issue gets a score from 0 to 100 where higher is riskier. The function's comment states the intent: it blends baseline slip, dependency-chain depth, deadline proximity and buffer depletion — "a risk signal competitors' manual RAG dropdowns can't produce" — and it is read-only, "pure derivation, never writes a field".

The score

if statusCategory === 'done':  score = 0
else:
    slip      = max(0, dueDate − _original.dueDate) in calendar days   // 0 if either is missing
    depth     = longest chain of in-plan predecessors ending at this issue
    deadline  = 30 if overdue,  else 15 if at risk (0..5 days),  else 0
    depletion = (buffer === 'Yes') ? clamp(1 − currentDuration / originalDuration, 0, 1) : 0

    score = slip×4 + depth×3 + deadline + depletion×25

score = clamp(round(score), 0, 100)

The four inputs and what each is worth

InputSourcePointsPoints to reach 100 alone
Cascade-derived slipcurrent dueDate minus the dueDate captured from Jira at index time (_original). Negative slip is clamped to 0.4 per calendar day late25 days of slip
Chain depthlongest predecessor chain, counted over in-plan predecessors only, with a cycle guard. Predecessors are the inward side of the configured Jira link type — "Blocks" by default — so effectively the in-plan issues that block this one.3 per level34 levels
Deadline proximitythe same overdue / at-risk test as the KPI tiles30 overdue, 15 at risk, else 0never
Buffer depletiononly for issues with Buffer = Yes: fraction of the original duration consumed (original falls back to the current duration when _original.duration is missing)up to 25 (a fully-consumed buffer)never

Bands

ScoreBandLegend labelColour
60 – 100redHigh--lz-error
30 – 59amberMedium--lz-buffer
0 – 29greenLow--lz-success

What the card renders

A single horizontal RAG bar whose three segments are sized in proportion to the number of issues in each band (a band with zero issues renders no segment; each segment carries a "High: N" style hover title), then a legend reading "High N", "Medium N", "Low N". Under it, the highest-scoring issues: the list is sorted by score descending, cut to the first 6, and only then are zero-score rows dropped — so it shows at most 6 rows and can show fewer. Each row shows the score in a band-coloured pill, the issue key and the summary, and clicking (or Enter/Space on) a row opens that issue in Jira's issue modal.

LimitThe whole card is hidden when there are no red and no amber issues. It is also hidden on a plan where every leaf is done — done work scores exactly 0 regardless of how late it finished.
CarefulSlip is measured against _original — the values read from Jira the last time the plan was indexed — not against a baseline you set. Re-indexing the plan resets _original to whatever Jira now holds, so applying your changes to Jira and re-indexing zeroes the slip component for every issue. Use Baseline & variance if you need slip that survives an Apply.
NoteOnly leaves are scored. Parents are excluded via the shared leaf rule, so an epic never shows in the top-6 list even when all of its children are red.

32Buffer health — per buffer and the pool figure

How each buffer's remaining shock absorption is measured, the exact exhausted predicate, the pool average, and the colour rules of the fever chart.

The card is headed "Buffer health" with the hint "shock-absorption remaining before a date slips for real". A buffer is any issue whose Buffer field is Yes. When the cascade pushes work later it compresses buffers before it moves real dates, so a buffer's remaining length is the plan's remaining slack.

Per-buffer arithmetic

original    = max(1, Number(_original.duration ?? duration) || 1)
current     = max(0, Number(duration) || 0)
compression = clamp(1 − current / original, 0, 1)      // 0 = untouched, 1 = fully compressed
exhausted   = (buffer === 'Yes') AND Number(duration) === 1 AND startDate === dueDate
health      = exhausted ? 0 : max(0, 1 − compression)   // i.e. current / original

The pool figure

total      = number of issues with Buffer = Yes (over ALL issues, not just leaves)
exhausted  = how many of those satisfy the exhausted predicate
avgHealth  = mean(health of every buffer row)     // 1 when the plan has no buffers at all
Pool %     = round(avgHealth × 100)

Reading the fever chart

The first line is labelled "Pool" and shows one bar plus the pool percentage; that bar is drawn in the exhausted (violet, full-width) style only when at least one buffer is exhausted AND the pool average is exactly 0. Under it, one row per buffer — issue key, summary, bar, and a value that reads either "exhausted" or "{current}/{original}d" (for example "4/10d": four working days left of an original ten). Rows are sorted by health ascending, so the most at-risk buffer is always first. If the plan contains no buffers the card reads "No buffer issues in this plan."

Bar colours

ConditionColourBar width
exhausted--lz-purple (violet — the cascade's danger note)forced to 100% so it cannot be missed as an empty bar
health > 0.5--lz-success (green)health × 100%
health ≤ 0.5 and not exhausted--lz-buffer (amber)health × 100%
Careful"Exhausted" is a precise three-part test — Buffer = Yes AND duration exactly 1 AND start equal to due — not "health reached 0". A buffer squeezed from 10 days to 2 has health 0.2 and is NOT exhausted; it shows amber with "2/10d". Conversely, a buffer that was created as a 1-day buffer and never touched IS reported as exhausted with health 0, because it already matches the predicate. That single case can drag the Pool percentage and the 25% Buffer-health share of the plan health score down for no real reason.
NoteThe comparison is against _original.duration — the buffer length Jira held at the last index. If _original.duration is missing, the current duration is used, which makes compression 0 and the buffer look pristine.
LimitThe Buffers KPI tile counts every issue with Buffer = Yes, including a parent that happens to carry the field in Jira, even though the Table view treats Buffer as a leaf-only setting and refuses to edit it on a parent.

33Milestone tracking

What the dashboard treats as a milestone, the four milestone states and their tests, and the slip figure shown next to each.

What counts as a milestone

The dashboard uses the shared predicate isMilestoneIssue, the same one the Gantt uses to draw a diamond. The comment explains why it is shared: the Milestone tracker used to carry its own "any single-day leaf" heuristic, and after the Gantt moved to declared-only milestones "the two views [would have been] disagreeing about the same plan". The dashboard only ever tests leaves, so the predicate's isParent argument is passed as false.

An issue is a milestone when ALL of these hold

  1. 1It is not a parent (parents' dates roll up, so they can never be a point in time).
  2. 2Its Buffer field is not Yes (an exhausted buffer is its own state, drawn violet).
  3. 3It has a startDate and startDate === dueDate — it is genuinely a single point.
  4. 4AND either its Jira issue type is literally "Milestone" (case-insensitive; the set MILESTONE_ISSUE_TYPES holds exactly that one name), or its duration is present and numerically 0. A null or undefined duration does not qualify — the code tests duration != null && Number(duration) === 0 precisely so a missing value is never mistaken for a declared zero.
NoteThe rule used to be "start === due && duration <= 1", which turned every one-day task into an unlabelled diamond and, worse, turned ordinary tasks into diamonds mid-drag, because a cascade that collapses a task to one day also collapses due onto start. As the code puts it: a milestone is now something you DECLARE, not something a schedule change can accidentally create. To get a milestone on the dashboard, give the issue Jira type "Milestone" or set its PPM duration to 0.

Milestone states (d = due date − today, in calendar days)

StateTestDot
donestatusCategory === 'done' (checked first, so a completed milestone is never 'missed')lz-dash-ms-dot--done
missedd < 0lz-dash-ms-dot--missed
at-risk0 ≤ d ≤ 5lz-dash-ms-dot--at-risk
on-trackanything else, including a milestone with no due date at alllz-dash-ms-dot--on-track

Milestone slip

slip = dueDate − _original.dueDate, in whole calendar days
     = 0 when either date is missing

Displayed only when slip ≠ 0, as "+3d" (class is-late) or "−2d" (class is-early).

Each row shows a state dot, the issue key, the summary, the current due date exactly as stored (the ISO YYYY-MM-DD string, unformatted), and the slip badge. Clicking a row, or pressing Enter/Space on it, opens the issue in Jira. Each row also carries data-state, data-slip and data-date attributes for the automated tests. The whole card is hidden when the plan has no milestones.

34Baseline and variance

How to freeze a plan, where the snapshot is stored, the variance arithmetic, and the honest limits of the 'net slip' figure.

Setting one

The card "Baseline & variance" shows the hint "Freeze the current plan to track slip over time" until a baseline exists. The button reads "Set baseline" the first time and "Update baseline" afterwards ("Saving…" while in flight, with both buttons disabled); a "Clear" button appears once a baseline exists. Setting one snapshots every loaded issue as { startDate, dueDate, duration } — each field stored as null when absent — under the KVS key p:{planId}:bl together with a takenAt ISO timestamp and a count. Once set, the hint becomes "Baseline set YYYY-MM-DD · N issues tracked", where N is the number of issues the variance calculation actually matched, not the number stored.

NoteThe baseline is deliberately a SEPARATE record from _original. As the resolver's comment says: "It is display-only variance data — it is NOT the live _original Jira-diff snapshot the Apply flow depends on, and it never enters the cascade or the write path." Setting or clearing a baseline cannot affect your schedule, your Apply, or what gets written to Jira.

Variance arithmetic (per issue that exists in both the baseline and the plan, and has a due date in both)

tracked += 1
d = current dueDate − baseline dueDate, in whole calendar days

if d > 0:   net += d ;  slipped += 1
if d < 0:              ahead   += 1
if d ≠ 0:   add a row {key, summary, slip: d}

rows sorted by |slip| descending, first 6 displayed

What the panel shows

A single large figure — "+14d" in red when net > 0, otherwise green — captioned "net slip · {slipped} late / {ahead} ahead", beside the six largest movers. Each mover row shows its signed slip badge (+Nd late / −Nd early), the key and the summary, and opens the issue in Jira when clicked or activated by keyboard. The panel carries data-net, data-slipped, data-ahead and data-tracked for the tests, and is hidden entirely when nothing has moved.

Careful"Net slip" is not a net. Only positive slip is accumulated into net; issues that moved EARLIER increment the ahead counter but never subtract from the total. A plan with one task 10 days late and one task 10 days early reports "+10d net slip · 1 late / 1 ahead", not 0. Read the figure as "total days of lateness", and read the late/ahead pair beside it for balance.
LimitThe snapshot is capped at 2000 issues (the resolver stops after 2000 entries, in whatever order the object yields them, to stay under the KVS value-size limit — values must stay under 240KiB). On a larger plan the baseline silently covers only part of it, and the "N issues tracked" figure will be lower than your issue count. Also, only issues with a due date on BOTH sides are tracked: an issue that gained or lost its due date since the baseline is skipped, not reported.
NoteVariance is measured against the plan as loaded in the browser, so unsaved staged changes count as slip immediately. The baseline is fetched once per plan when the dashboard mounts (and re-fetched right after you set one); a failed fetch is swallowed and simply shows no baseline.

35Resource workload per assignee

The weekly concurrency heatmap: what a cell counts, the over-allocation threshold, the colour rules and the hard caps on what is displayed.

The card is headed "Resource workload" with the hint "concurrent tasks per assignee · red = over-allocated". The function's comment sets the boundary plainly: it "REPORTS contention (and flags over-allocation); it never reschedules anything. No resource leveling."

How the grid is built

  1. 1Eligible issues: leaves only, that have an assigneeName AND a startDate AND a dueDate. Everything else is invisible to this chart.
  2. 2Find the earliest start and latest due across those issues.
  3. 3Week buckets start on the Monday on or before the earliest start, and step 7 days at a time while the cursor is still on or before the latest due — capped at 26 buckets (maxWeeks). Each bucket runs from its Monday to that Monday + 6 days.
  4. 4Cell value = for that assignee, the number of eligible tasks that OVERLAP the week, tested as (task.start ≤ week.end AND task.due ≥ week.start).
  5. 5Per assignee: peak = the largest cell, total = the sum of all cells, over = peak > 3 (overThreshold).
  6. 6Assignees are sorted by total descending and the first 12 are shown (maxAssignees).

Cell colours (threshold = 3)

Cell valueColourMeaning
0--lz-border (grey)nothing scheduled that week
1 – 2--lz-success (green)comfortable
exactly 3--lz-buffer (amber)at threshold
4 or more--lz-error (red)over-allocated

Labels and tooltips

Only every fourth week carries a printed label (index % 4 === 0), formatted as short month plus day-of-month ("Jun 1"). Hovering any cell gives "{assignee} · {week label}: N tasks" (singular "task" for one). An assignee whose peak exceeds the threshold gets a flag marker before their name, titled "Over-allocated".

CarefulA cell counts TASKS IN FLIGHT, not effort. Three one-hour tasks and three month-long tasks both read as 3. There is no capacity model, no hours, no part-time allocation and no leave — the threshold of 3 concurrent tasks is a fixed default in the function signature, not a per-person setting, and the dashboard calls computeWorkload with no overrides.
LimitThree hard caps are applied silently: 26 weeks from the first start (later work simply is not drawn), 12 assignees by total load (the 13th person is dropped), and eligible issues only. Unassigned work and work missing either date contribute nothing, so a heatmap that looks empty may just mean the plan has no assignees set — and if nothing is eligible the whole card disappears.
NoteWeeks are computed in UTC and start on Monday (the code shifts the earliest start back by (getUTCDay() + 6) % 7 days).

36The two CSV exports

The Export CSV and Health report buttons: their exact columns, filenames, escaping rules, and the one column that is always blank.

Both buttons sit in the toolbar at the top of the Dashboard. Both are generated entirely in the browser and downloaded as a Blob (text/csv;charset=utf-8) via a temporary link — there is no server round trip and nothing is stored. Each file starts with a header row of the column names, then every issue in the plan gets a row, in the plan's own order, parents included and with no filtering.

Button 1 — "Export CSV" (title: "Download all issues as CSV")

#ColumnContents
1keyJira issue key
2summarysummary, truncated by the indexer to the configured maximum (indexing.maxSummaryLength, default 80 characters)
3typeJira issue type name (the indexer writes "Unknown" when absent)
4statusJira status name ("Unknown" when absent)
5startDatecurrent planned start (YYYY-MM-DD), including unsaved edits
6dueDatecurrent planned due date
7durationworking-day duration
8bufferYes / No
9assigneeNameassignee display name
10parentKeyin-plan parent key, or empty (a parent that exists in Jira but is outside the plan is nulled at index time)

Button 2 — "Health report" (title: "Download the computed health analytics (risk, buffer health) as CSV")

#ColumnContents
1keyJira issue key
2summarysummary
3statusJira status name
4dueDatecurrent due date
5riskScore0–100 schedule-risk score; empty for parents (only leaves are scored)
6riskBandred / amber / green; empty for parents
7baselineSlipDaysalways empty — see the gotcha below
8bufferHealthPctround(health × 100) for buffer issues; empty for non-buffers
9bufferExhaustedyes / no for buffer issues; empty for non-buffers
CarefulThe baselineSlipDays column is always blank. healthReportToCsv accepts a slipByKey argument, but the Dashboard calls it with only riskByKey and bufferByKey, so nothing ever populates that column. If you need slip figures, read them off the Baseline & variance panel — the export does not carry them.

Filenames and confirmations

ButtonFilenameToast
Health reportplan-health-{planId}-{YYYY-MM-DD}.csv (planId replaced by "export" if absent; the date stamp is today's UTC date)"Health report exported"
Export CSVplan-{planId}-{YYYY-MM-DD}.csv (same rules)"Issues exported to CSV"

Escaping

A value is wrapped in double quotes only when it contains a comma, a double quote or a newline, and embedded double quotes are doubled — so a summary of Has, comma and "quote" is emitted as "Has, comma and ""quote""". Null and undefined become empty strings. Rows are joined with bare newlines and there is no BOM, which matters if you open the file in a spreadsheet that guesses the encoding.

LimitNeither export includes predecessors, successors, labels, priority, story points or WBS, and neither respects the Table view's filter, sorting, grouping or column choices. For a column set that matches what you configured on screen, copy out of the Table view instead.

37Table view: columns, the column picker and layout

The six always-on columns, the fourteen optional ones grouped into six categories, how the Key and Summary widths are derived, and exactly what persists between sessions.

The Table tab is a spreadsheet over the same plan data the Gantt draws, and it edits through the same cascade. PlanView renders the same CascadeImpact blast-radius strip — with its Explain action and clickable issue keys — directly above the grid, exactly as it does in the Gantt; the source comment there reads "Table edits cascade too — show the same blast-radius strip + Explain."

Primary columns — always shown, cannot be turned off

ColumnHeader labelDefault widthEditable
keyKey (headers are upper-cased by CSS)auto-sized, minimum 90pxNo (the key itself opens the Jira modal)
summarySummaryfills remaining space, minmax(150px, 1fr); drag-resizableNo
startDateStart Date100pxYes, on leaves
dueDateDue Date100pxYes, on leaves
durationDuration80pxYes, on leaves
bufferBuffer70pxYes, on leaves (click to toggle)

Optional columns — the "Columns" picker, by group

GroupColumns (width)
IssueType (90), Status (110), Priority (90), Resolution (100), Labels (150)
PeopleAssignee (130), Reporter (130)
HierarchyParent (90), WBS (80)
DependenciesBlocked By (120) — the predecessors field; Blocks (120) — the successors field
EstimationStory Points (90)
DatesCreated (95), Updated (95)

Using the picker

Click the "Columns" button in the table toolbar. A dropdown lists the fourteen optional columns under their six group headings; each is a toggle button with a check box and aria-pressed state, and the Columns button carries a badge with the number of extra columns currently on (the badge is hidden at zero). The dropdown closes on an outside mousedown. Enabled columns are appended to the right of the six primary ones, in the order listed above (the picker order), not in the order you clicked them.

Key column auto-width

maxDepth   = deepest visible tree depth (forced to 0 while grouped)
longestKey = longest issue key length in the plan

width = max(90, 14 + ceil(longestKey × 7) + 24 + maxDepth × 14)

(a plan with no issues at all simply uses 90)

The reason is given in the code: without it, "deeply-indented keys wrap to two lines and make the whole row look crammed". Each tree level indents by 14px (TREE_INDENT). Grouping removes indentation entirely and reclaims that width.

Summary column resize

Drag the 5px handle on the right edge of the Summary header (it highlights in the primary colour on hover). The minimum is 120px. Until you drag it, the column is elastic (minmax(150px, 1fr)); once dragged it becomes a fixed pixel width. The whole grid has a minimum width of 36px (the checkbox column) plus the sum of all column widths, using 250px for Summary when it has not been resized — below that the table scrolls horizontally.

What persists, and where

SettingPersisted?Storage
Chosen extra columns, sort column, sort direction, summary widthYes, across sessionslocalStorage key ppm.display.table.layout, as one JSON object {extraCols, sortCol, sortDir, summaryWidth}
Group-by field and collapsed groupsNo — session onlydeliberately not persisted
Expand/collapse of parentsYes, per planowned by PlanView and shared with the Gantt; localStorage key ppm.display.expanded.{planId}
CarefulThe layout key is GLOBAL, not per plan: your column choice, sort and summary width follow you into every plan you open. Group-by is deliberately excluded for exactly that reason — the code notes that persisting it "would silently re-group every other plan". If localStorage is unavailable (private browsing, quota) the layout is simply not restored; the read and the write are both wrapped in try/catch and nothing errors.
NoteStory Points reads Jira field customfield_10016, which is hard-coded in the indexer (unlike start date, due date, duration, buffer and rank, which go through the configurable field map). On a site where story points live in a different custom field the column will be empty for every row.

38Table view: sorting, the quick filter and group-by

How sorting interacts with the tree, exactly what the filter matches and why it keeps ancestors, the seven grouping fields with their labels and colours, and how the row counter behaves in each mode.

Default order

With no sort and no grouping, rows are in tree order: a depth-first walk where parents precede their children, sibling order at every level follows the plan's own issue array (Jira LexoRank, per the code comment), and children of a collapsed parent are not rendered at all. Each row is indented 14px per level; parents get a rotating chevron, non-root leaves get a ↳ branch mark, and root-level leaves get a blank 14px spacer so the keys still line up.

Sorting

  1. 1Click any column header — including Key and Summary — to sort ascending; click the same header again to flip to descending. Headers are also keyboard-activatable with Enter/Space. The arrow ▲ / ▼ shows the active column and direction, and aria-sort is set for screen readers.
  2. 2Sorting switches the grid from tree order to a FLAT list of every issue. Indentation is kept so depth is still readable, but collapsing is not applied — children of a collapsed parent reappear while sorted. The code states the reason: otherwise "children would vanish on sort".
  3. 3Rows with no value in the sorted column (null, undefined or empty string) always sort LAST, in both directions. The comment explains: sorting by a column "should surface the rows that HAVE a value, not bury them under ~100 blank rows".
  4. 4Two numbers are compared numerically; everything else is compared with localeCompare on their string form. Array columns (Blocked By, Blocks, Labels) therefore sort by their comma-joined text, e.g. "LZPT-4,LZPT-9".

The quick filter

The search box in the toolbar has the placeholder "Filter tasks…" and matches a case-insensitive substring against the issue KEY or the SUMMARY only. No other field is searched, and there is no syntax — it is a plain contains test on the trimmed text. A × button (title "Clear filter") appears once you type. When nothing matches, the grid is replaced by "No tasks match “your text”" plus a "Clear filter" button; with no filter text an empty grid instead reads "No tasks match the current view" and offers no button.

NoteMatched rows keep their ancestors. When a child matches, the filter walks up the parentKey chain and keeps every in-plan ancestor as well, "so a matched child still renders under its epic". Those ancestor rows are context, not matches — the count includes them. Ancestor preservation is skipped while grouped, since the tree has dissolved and, in the code's words, it is "a TREE affordance — pointless when grouped".

Group-by options (the "Group" dropdown — a custom Select, not a native one)

OptionGroups byGroup label when the value is emptyColour of the group chip
No grouping——— (tree view returns)
Statusstatus name"No status"by the first member's category: done green #22c55e, in progress blue #3b82f6, else slate #64748b
Typeissue type"No type"rainbow, by group position
Prioritypriority name"No priority"Highest #ef4444, High #f97316, Medium #f59e0b, Low #3b82f6, Lowest #94a3b8, any other value #94a3b8
AssigneeassigneeName"Unassigned"rainbow, by group position
ParentparentKey"No parent"rainbow, by group position
Bufferbuffer, normalised to Yes/No (so there is never an empty bucket)—"Buffered" orange #f97316, "No buffer" slate #64748b
NoteThe empty bucket is colour-coded before any per-field rule runs, so "No status", "No type", "No priority", "Unassigned" and "No parent" all get the same slate chip #94a3b8 regardless of which field you grouped by.

How groups are ordered and labelled

Groups are sorted alphabetically by value (localeCompare), except Priority which uses the rank Highest, High, Medium, Low, Lowest with any unrecognised priority ranked after those, and except the empty bucket which is always sorted LAST. A Parent group is labelled "KEY · first 40 characters of the parent's summary", resolved from the full unfiltered issue list so the label still works when the parent row itself is filtered out; if the parent is not in the plan at all the label is just the key. Rainbow colours cycle through a fixed ten-hue palette by group index (#2563eb, #a855f7, #f97316, #16a34a, #e11d48, #0891b2, #d97706, #7c3aed, #059669, #db2777).

Behaviour while grouped

Each group is a full-width header row with a chevron, the colour chip, the label and a count badge; clicking the header (a real button with aria-expanded) collapses or expands that group. The tree dissolves: rows appear in the plan's flat LexoRank order within each group, indentation is dropped, parent chevrons disappear and the WBS column shows an em dash — "WBS is a tree positional outline — meaningless once regrouped". Changing the group field resets all collapsed groups. The "Show children" expand-all toggle is hidden while grouped.

What the row counter (top left) says

ModeText
Tree, no filter"{N} issues" — N counts VISIBLE rows, so collapsing a parent lowers it
Tree, filtered"{matched} of {N}" — both counts include kept ancestors
Sorted, no filter"{N} issues" where N is now every issue in the plan, because sorting flattens
Grouped, no filter"{N} · {G} groups" — N is every issue in the plan (grouping flattens; collapsing a GROUP does not lower it), singular "group" when G is 1
Grouped, filtered"{matched} of {total} · {G} groups", where total is the whole plan
CarefulThe group count badge counts every member of the bucket including parents, while select-all and the group header's own check box deliberately exclude parents. A group badge reading 12 can therefore fill only 9 check boxes. That is intended: the only bulk action is a leaf-only setting.
LimitGrouping is flat — each issue lands in exactly ONE bucket by its own field value. There is no nested grouping, no group-level rollup of dates or durations, and no second-level sort key (sorting, when active, applies within each group because the buckets are filled from the already-sorted list).

39Table view: selection, bulk edit, inline editing and the chips

Multi-select and the bulk buffer bar, the exact arithmetic behind inline date and duration edits, what the amber cell background means, and the meaning of every status, priority and buffer chip.

Multi-select

  1. 1Each leaf row has a check box in the leading 36px column. PARENT rows have no check box at all — dates and buffer are rolled-up or leaf-only concepts.
  2. 2The header check box (title "Select all visible rows") selects or deselects every currently selectable row, and shows an indeterminate state when only some are selected. "Selectable" means: visible after the filter, not inside a collapsed group, and not a parent.
  3. 3Select-all only unions or subtracts the CURRENTLY selectable keys. Selections in filtered-out or collapsed rows are preserved untouched — the comment calls this "never a surprise".
  4. 4While grouped, each group header carries its own check box scoped to that group's leaf members (hidden entirely for a group with no leaves).

The bulk bar

As soon as one row is selected, a floating bar appears fixed at the bottom centre reading "{N} selected", then "Set buffer" with a "Yes" and a "No" button, then "Clear". Those two buttons are the only bulk actions in the table. Applying one writes the buffer field to every selected key in a SINGLE pass followed by a SINGLE full-plan cascade — the code is explicit that this is not a loop of individual edits, because such a loop "closes over stale issues and only the last would stick". Issues that already hold the target value, and any issue that has Jira subtasks (a non-empty children array), are skipped; if that leaves nothing changed, no cascade runs at all. The selection is cleared afterwards.

Inline start / due date editing

  1. 1Click a Start Date or Due Date cell on a leaf row; a date picker opens over the cell. Parent cells show the tooltip "Rolled up from children" and do not open. An empty date cell reads "Click to set" on a leaf and an em dash on a parent.
  2. 2When you pick a date, the app also fixes up the other two fields: if a start and a duration exist but no due date, dueDate = the start pushed forward to the next working day, then advanced by duration working days — the helper is 1-indexed, so a duration of 1 means start = due.
  3. 3If both dates are now known, duration is RECOMPUTED as the count of working days between them, inclusive, with any falsy result (0) replaced by 1.
  4. 4The change is then handed to PlanView, which runs the iron-clad rule and the buffer cascade from that issue — a table edit propagates through successors exactly like a Gantt drag and feeds the Cascade Impact strip and the Explain panel. The ~800ms row flash, though, is a Gantt-only affordance: table rows never flash. In the Table the visible feedback is the Cascade Impact strip above the rows, plus the amber staged background on every cell the cascade changed.

Inline duration editing

  1. 1Click a Duration cell on a leaf row to get a numeric input with a "days" suffix; the existing value is pre-selected. Only digits and Backspace, Delete, ArrowLeft, ArrowRight, Tab, Enter and Escape are accepted — other keystrokes are swallowed.
  2. 2Enter commits (by blurring), Escape cancels, and clicking away commits.
  3. 3An empty value is allowed and clears the duration (unscheduling the task). A non-empty value that is not a positive integer — 0 or garbage — is REJECTED silently and the cell reverts, because "a scheduled task can't be 0 days, and setting 0 left a stale due date".
  4. 4On a valid value, if the issue has a start date the due date is recomputed as the start (pushed to the next working day) plus N working days, and the cascade runs.

Buffer cell

On a leaf, the Buffer cell is a one-click toggle between Yes and No, which also triggers the cascade. On a parent it shows an em dash with the tooltip "Buffer is a leaf-task setting".

NoteA cell with an amber background (--lz-buffer-light) is a STAGED change: its value differs from _original — the value read from Jira at the last index. Start, due, duration and buffer are each compared individually (duration as strings, with a missing original treated as empty; buffer against "No" when the original is unset). Amber cells are what the Apply button will write to Jira.

Status chip

Jira status categoryChipText shown
donesolid green #16a34a, white textthe Jira status name
indeterminatesolid blue #2563eb, white textthe Jira status name
new or anything elsesolid slate #64748b, white text (the base chip style)the Jira status name, or "Unknown" when Jira sent none

Priority chip (all solid fills with white text — no faded tints)

Priority valueColour
Highest, Blocker#dc2626 red
High, Critical#ea580c orange
Medium#d97706 amber
Low#2563eb blue
Lowest#64748b slate
anything else, or unsetno fill — outlined chip showing the raw value, or an em dash when empty

The other cell renderings

ColumnRendering
Keymonospace, bold, primary-coloured with a dotted underline; clicking it opens Jira's native issue modal
Durationthe number with a "d" suffix ("5d"); "Set" on an empty leaf, an em dash on a parent
Bufferan uppercase chip reading BUFFER on solid orange when Yes, or a muted NO when not
Blocked By / Blocksone clickable key badge per linked issue; each opens that issue in Jira. Em dash when there are none
Parentthe parent key as a clickable badge, or an em dash
Labelsone violet tag per label, or an em dash
Resolutiona green pill with the resolution name, or an em dash
WBSoutline number from the tree walk (1, 1.1, 1.1.1 …); an em dash while grouped
Created / Updatedthe indexed date (Jira's timestamp truncated to YYYY-MM-DD at index time), formatted short
Story Pointsthe raw number, or an em dash
CarefulAll dates in the table are rendered by formatDateShort in the form "Jun 3" — WITHOUT the year, in UTC. On a plan that spans more than one year, two rows reading "Jan 5" can be twelve months apart. Use the Gantt, or export to CSV (which writes full ISO dates), when the year matters.
NoteWorking days are enforced on the way in, not just on the way out. The table computes a due date from the start pushed forward to the next working day, and the cascade then snaps the changed issue's own start (and due) onto working days as well, recomputing the due date from the snapped start when a duration exists. Pick a Saturday start with a duration of 5 and you end up with a Monday start, not a Saturday one.
LimitOnly start date, due date, duration and buffer are editable in the Table, and only on leaves. Summary, assignee, priority, status, labels and story points are read-only here — change them in Jira and re-index.

40Staged, saved, applied — the three states of an edit

The core mental model: edits live in your browser, then in the app's own storage, and only reach Jira when you Apply.

Every change you make in the timeline or the table happens in your browser first. Dragging a bar, typing a date, drawing a dependency arrow, deleting one, dragging a row into a new position — none of it contacts Jira. An edit passes through three distinct states, and only the third one writes to a Jira issue.

The three states

StateWhere the change actually livesWhat puts it thereTouches Jira?Survives a browser reload?
StagedReact state in your browser tab onlyAny edit in the Gantt or Table: bar drag, date edit, duration/buffer change, new dependency, deleted dependency, row reorderNoDate, duration and buffer edits: yes, restored from your autosaved draft. New/removed dependencies and rank moves: no — they are lost.
SavedThe plan's own storage (KVS issue shards) inside the appThe Save button, or the 60-second autosaveNoYes
AppliedThe Jira issues themselvesApply → Review Changes → "Apply N Changes"YesYes — it is in Jira

Two counters, two different baselines

The toolbar shows two numbers and they are not measuring the same thing. This is the single most common source of confusion.

Save (N)
Issues whose fingerprint differs from the last storage checkpoint. The fingerprint is startDate|dueDate|duration|buffer|predecessors|successors. When N is 0 the button is disabled and reads "Saved".
Apply N changes
Issues whose startDate, dueDate, duration or buffer differ from _original — the values Jira held when the plan was last indexed — plus every staged new dependency, removed dependency and rank move. The button only appears when this total is above 0.
CarefulSaving does not reduce the Apply count, and Apply is not a kind of Save. The code states it plainly: "Save = persist to KVS only (plan snapshot). Apply = write to Jira tickets. These are separate: Save doesn't affect bar colors or Apply count." Bar colours and the cascade impact strip are both computed from the current-vs-_original delta, so they stay lit after a Save and only clear once a successful Apply re-indexes the written issues and gives them a fresh _original.
NoteOne asymmetry worth knowing: the Save fingerprint DOES include predecessors and successors, so drawing or deleting an arrow raises the Save (N) counter — even though the Save payload deliberately never sends the dependency lists. Saving then rebuilds the checkpoint from the issues as they are, so the counter drops without anything having been persisted about that arrow.
LimitStaged dependencies and rank moves exist only in your browser tab. The autosaved draft carries four fields per issue — startDate, dueDate, duration, buffer — and nothing else. If you draw five arrows and reload the page before applying, those five arrows are gone. Date edits would have come back.
LimitThe Save and Apply buttons are rendered only in the Gantt and Table views. Switching to Dashboard, Schedule or Permissions hides them (and the drafts badge, the Delete button and Re-index). Your edits are still there; switch back to Gantt or Table to save or apply them.

The one exception: link lag

Setting a finish-to-start lag on a dependency arrow is not staged. It updates the plan immediately (setLinkLag writes straight to the plan's lag map at p:{planId}:deps), re-settles the schedule locally with the same cascade engine, and if that write fails the app reverts just that one edge and tells you: "Couldn't save lag: … — reverted." The revert is surgical — it is skipped if you have since changed the same edge again. Lag never reaches Jira at all, because Jira issue links carry no lag; it is app-private data stored in its own key so a re-index cannot wipe it. The date shifts the lag causes do flow into the normal Apply diff.

41What Save writes, and what it deliberately refuses to write

Save checkpoints four scheduling fields into the plan's own storage — never into Jira, and never the dependency graph or the Jira baseline.

Save calls savePlanState and sends a deliberately minimal payload: for each changed issue, exactly four fields. Everything else in your in-memory issue object is withheld.

The complete Save payload, per issue

FieldValue sentFallback when empty
keyThe issue key(required — an issue with no key is dropped)
startDatei.startDatenull
dueDatei.dueDatenull
durationi.durationnull
bufferi.buffer"No"

On the server these four values are merged into the stored issue row ({ ...issue, ...patch, key: issue.key }). The rest of the row — summary, status, hierarchy, children, dependency lists, the Jira baseline — is left exactly as the indexer wrote it.

Three things Save refuses to send, and why

  • predecessors / successors. A dependency you have only DRAWN does not exist in Jira yet. Writing it into plan storage produced a graph that was part real and part fiction; a partial re-index after an Apply only refreshes the date-changed keys, so the phantom edges survived, got baked into the next baseline, and then rendered as staged arrows that could be neither applied nor discarded.
  • _original, the Jira baseline. Save used to overwrite it with the frontend's normalised duration. The external-conflict check compares that baseline against raw Jira, so on any plan without a PPM Duration field it then reported a phantom "Duration changed in Jira" conflict and aborted the entire Apply.
  • predecessorLags. Lags are denormalised onto issues when the plan loads, but deliberately live in their own storage key so that a re-index cannot wipe them. Round-tripping them through Save would defeat that.

What one Save does

  1. 1The Save button flashes and shows a spinner with "Saving...".
  2. 2savePlanState merges the four fields into the plan's issue shards, running at most 5 shard writes concurrently.
  3. 3The local checkpoint (kvsSnapshot) is rebuilt from the issues as they are at that moment, so the Save (N) counter drops to 0.
  4. 4The plan's version number is incremented by 1. Nothing is broadcast — another tab picks the change up on its next version check, which runs when you switch views.
  5. 5Toast: "Saved N issues to plan".
CarefulIssues that are not in the plan index are skipped by the shard write. This is normal for an issue created since the last index. You will see: "N issues couldn't be saved — not in the plan index yet. Re-index the plan." Before this warning existed, Save reported success having stored nothing for those issues.
NoteAutosave runs every 60 seconds whenever the unsaved count is above 0, and runs silently — no toast on success and, importantly, no toast on failure either. The Save (N) counter is your real signal: the checkpoint is only rebuilt after the save call returns, so a save that throws leaves the counter standing.

The "saved X ago" indicator

Once everything is saved, the toolbar shows a green tick and a relative time next to the Save button: "just now" under 5 seconds, "Ns ago" under a minute, then "Nm ago". It is recomputed every 10 seconds and is hidden while there are unsaved changes or a save in flight.

LimitsavePlanState has no server-side permission gate. Plan roles (viewer / editor / owner / admin) are enforced on the Jira write path and on recalculation — startWrite, createDependency, deleteDependency, setLinkLag, rankIssue, recalculateFromIssue and recalculatePlan all call requireEdit — but a Save only writes to the app's own plan storage and is not role-checked. Neither are saveDraft and deleteDraft. Treat plan membership as the control for who can reshape a plan, not as a hard stop on writing plan data.

42Drafts — your unsaved work, and everyone else's

A per-user autosaved draft keeps unsaved edits across reloads and re-indexes, and makes other people's work-in-progress visible.

A draft is not the same thing as a Save. Save is the plan's shared checkpoint; a draft is your private copy of the edits you have not checkpointed yet. It exists so that a reload, a re-index, or the hourly background refresh cannot silently throw your work away.

Draft mechanics

AspectBehaviour
TriggerDebounced 1500 ms after your last edit, while the unsaved count is above 0
ContentsPer issue: startDate, dueDate, duration, buffer. Plus affectedKeys and a flat calculatedResults list.
Storagep:{planId}:d:{accountId} — one draft per user per plan, plus a lightweight registry at p:{planId}:drafts that everyone polls
DeletionAutomatic once the unsaved count drops back to 0 (a Save, an Apply, or Discard All all do this). Also deleted server-side after your own successful Apply.
RestoreOverlaid once per load cycle, after the local checkpoint baseline is set, then the whole plan is re-settled by the cascade so successors follow
ExpiryDrafts older than 24 hours, and drafts flagged stale, are deleted by the housekeeping pass that rides along with the hourly scheduled refresh (one pass per plan, in the queue consumer)
NoteBecause the draft is overlaid AFTER the checkpoint baseline is set, restored edits correctly read as unsaved — the Save (N) counter comes back with the number you had before the reload, rather than the edits being silently absorbed into the baseline.

Why Re-index does not ask you to confirm

Re-index pulls fresh Jira data for the plan and resets the local checkpoint, which re-arms the draft overlay — your unsaved edits are re-applied on top. That is why the Re-index button has no "you will lose changes" confirmation: a mis-click cannot lose edits. A genuine clash with Jira surfaces later, at Apply, through conflict detection.

Other people's drafts

A warning-bordered banner appears under the toolbar whenever anyone else has an active draft on this plan: "N active drafts", each person's display name with their issue count in brackets, and a total ("· 12 total issues affected"). The toolbar also carries a "N drafts" badge. Both count other users only — the server excludes your own account from the list before it is returned.

Stale drafts

When someone applies changes to Jira, every other user's draft that touches any of the written issues is flagged stale, with a reason recorded ("Issues were written by another user at …"), and the registry entry is flagged too. A stale draft is never overlaid on load — the overlay function returns the issues untouched when draft.stale is set — so you are not silently re-applying edits that were computed against superseded dates.

LimitA draft carries date-shaped edits only. Dependencies you drew or deleted, and rank moves, are not in it. If you close the tab with staged arrows, they are gone; there is nothing on the server to restore.

43The Review Changes dialog

The confirmation gate before anything reaches Jira: every pending change listed, individually tickable, with Discard All as the escape hatch.

Pressing "Apply N changes" does not write anything. It opens the Review Changes dialog, which is the complete manifest of what is about to be sent. The subtitle reads "N changes will be written to Jira", followed in orange by "(M discarded)" once you untick anything. If the dialog is somehow opened with nothing pending, it closes itself rather than stranding you.

The four sections, in order

Section headingWhat it listsRow contentCount shown
Date ChangesIssues whose startDate, dueDate, duration or buffer differ from the Jira baselineIssue key, summary, and one orange tag per changed fieldactive / total
New DependenciesDependency arrows you drew that do not exist in Jira yetFROM — blocks — TOactive / total
Removed DependenciesExisting Jira links you deleted in the appFROM — blocks (struck through, red) — TOactive / total
Rank ChangesRow reorders you draggedIssue key, then "before X" or "after Y"active / total

How a date diff is rendered

FieldFormatWhen the old value is empty
StartStart: <old, struck through> newshows "none"
DueDue: <old, struck through> newshows "none"
DurationDur: old → newdold shows as 0
BufferBuffer: old → newold shows as "No"

Unticking a row

Every row has a solid blue tick box (red for removed dependencies). Clicking it drops that row to 35% opacity and removes it from the count in the Apply button. The button label tracks live: "Apply 12 Changes", and it disables itself when you untick everything.

CarefulUnticking is not symmetrical between rows. An unticked DATE row is reverted in your view to the Jira baseline values, but that revert is not written back to plan storage — so if the 60-second autosave had already checkpointed that edit, a later reload can bring the discarded date back. An unticked DEPENDENCY or RANK row is dropped completely: it is neither written nor kept staged, so the arrow or move is simply gone. If you want a clean, durable discard, use Discard All.

Discard All

The red "Discard All" button in the footer throws away every pending change at once and, unlike unticking, persists that decision.

What Discard All does

  1. 1Reverts every listed issue's startDate, dueDate, duration and buffer to its _original Jira baseline in the view.
  2. 2Clears all staged links, unlinks and rank moves.
  3. 3If at least one date change was reverted: writes the reverted values back through savePlanState, updates the local checkpoint for those keys, and deletes your draft. (With only links or ranks staged there is nothing to write back, so this step is skipped.)
  4. 4If any staged unlink was discarded, reloads the issues from plan storage so the removed arrow reappears and the freed successor re-pins correctly.
  5. 5Toast: "All changes discarded".
NoteThe persistence step exists because discard used to be view-only. Once a manual Save or the 60-second autosave had advanced the checkpoint, discarding left the screen correct but storage still holding the edit — a "Save (N)" nag appeared out of nowhere and a reload resurrected the change the user had explicitly thrown away. If the write-back fails you are told: "Discarded here, but the plan couldn't be updated — reload to check".

The other footer buttons

Review with AI
Only shown when AI is enabled for the site (checked via getAiConfig when the dialog opens). Runs an advisory semantic pass over exactly three checks — semantic precedence conflicts, unrealistic compression, missing intended dependencies — and layers its results above this dialog. It is never a gate — you can always apply regardless of what it says, and it changes nothing in your plan. The footer of that modal shows a CACHED · 0 tokens pill when the result was reused, the model name, and input/output token counts; when a daily or monthly cap is hit it says so explicitly ("Daily AI review limit reached", with used-of-cap and "You can still apply").
Cancel
Closes the dialog and keeps everything staged. Escape does the same — unless the AI review is open on top, in which case Escape belongs to that modal.
Apply N Changes
Confirms. From here on, the write path in the next section runs.

44Apply — what actually happens to your Jira issues

The full write sequence: lock, conflict pre-check, chunked field writes, verification, re-index, unlock — plus the separate path for link and rank changes.

Confirming the Review Changes dialog starts one of two write paths, depending on whether your change set contains any date changes.

Which path runs

Your change setModal shownTakes the write lock?Steps
Contains at least one date/duration/buffer changeWrite progress (Starting → Writing → Verifying → Complete)YesDates are written first, then any links, unlinks and rank moves in the same run
Only dependencies and/or rank movesRank write progress (Starting → Writing → Refreshing → Complete; the Refreshing step only runs if at least one rank actually moved)NoLinks created, then links deleted, then ranks moved, then rank order refreshed

The date write, step by step

  1. 1startWrite: your plan role is checked (editor, owner or admin required — a viewer is refused here, and since every chunk requires holding the lock, a refused user cannot write at all). Your real display name is resolved server-side from Jira so the lock overlay names you correctly. The write lock is acquired and the plan status is set to "writing". A lock:acquired event is published.
  2. 2Conflict pre-check (first chunk only): every issue in the change set is re-fetched from Jira live and compared with the baseline you started from. Any difference aborts the whole apply before a single field is written, releases the lock, and returns the conflicting issues.
  3. 3Field filtering: the app reads Jira's edit metadata for the first issue in the chunk and drops any field that issue cannot accept.
  4. 4Writing: issues are written 10 at a time, one PUT per issue, with a 250 ms pause between issues inside a chunk. After each chunk the lock TTL is refreshed and a write:progress event is published for observers.
  5. 5Verifying: every written issue is re-fetched, parsed with the same transformer the indexer uses, and compared against the values you intended to write.
  6. 6Re-index: the written issues are refreshed from Jira into plan storage, your draft is deleted, other users' overlapping drafts are flagged stale, the plan version is incremented and the lock is released. lock:released and plan:version events tell everyone else to refresh.
  7. 7The modal shows "Applied" for about 1.1 seconds and dismisses itself; the plan reloads in place without the timeline jumping.

Exact fields written to Jira

App fieldJira field (default id — configurable in admin settings)Payload shape
Start datecustomfield_10015plain date string
Due dateduedateplain date string
Durationcustomfield_11581number
Buffercustomfield_12399{ value: "Yes" | "No" }

Write pacing and retry

SettingValueWhat it is for
Issues per chunk10One writeChunk call per 10 issues; each chunk re-checks that you still hold the lock
Delay between issues250 msStays under Jira's 50 PUT/sec burst limit. No pause is added between chunks — a BATCH_PAUSE_MS of 2000 is declared in the file but never used.
Lock TTL5 minutesRefreshed after every chunk; a run longer than 5 minutes with no chunk completing loses the lock
Retry attempts per request4Base 2 s, capped 30 s, jitter 0.7–1.3, honours a Retry-After header on 429. A 4xx that is not 429 is never retried.
Rough throughput~10 issues per ~2.3 s100 issues take roughly 25 seconds plus round trips
CarefulWrites are made by the app, not by you. Each issue is updated with PUT /rest/api/3/issue/{key}?notifyUsers=false&overrideScreenSecurity=true as the app (api.asApp), so the change history on the ticket is attributed to the app and no email notifications are sent to watchers or assignees. Link creation, link deletion and rank moves go the same way.
LimitA field the project cannot accept is dropped before the PUT. This is deliberate: a custom field the project never configured (commonly PPM Duration or Buffer on imported plans) makes Jira reject the ENTIRE request, which would silently kill the date write too. Consequence: if every field in a change gets dropped, that change is skipped entirely — counted as neither written nor failed. "7 written" out of 10 confirmed rows with zero errors is that case, not a bug.
LimitThe settable-field list is read from the FIRST issue in each chunk of 10 and applied to all of them. In a plan spanning projects with different edit screens, a field that is perfectly settable on issue 7 can be dropped because issue 1's screen lacks it. Verification is filtered the same way (from the first issue of the whole change set), so those drops do not fail the apply — they just do not happen.
LimitThe conflict pre-check is wrapped in a try/catch that only logs. If the live Jira fetch itself throws — a network blip, a permission error on the bulk fetch — the apply proceeds unchecked rather than aborting. Only a conflict that is successfully detected stops the write.

The link and rank pass

Links, unlinks and rank moves are written one item at a time in a fixed order — new dependencies first (they often inform the rank decisions), then removed dependencies, then rank moves — with the current operation named in the modal ("PROJ-1 → PROJ-2", "✕ PROJ-3 → PROJ-4", "PROJ-5 after PROJ-6"). A dependency is created as the configured link type (default "Blocks") with the predecessor as the blocker, and the app also updates the stored predecessor/successor lists so the edge reads as real after a reload. Deleting one first looks up the link id on the predecessor issue, and reports "Link not found" if the link is no longer there; a removed edge also has its stored lag cleared so a re-link doesn't resurrect it. After the rank pass, refreshRanks updates the stored rank values so the timeline shows the new order on reload.

LimitA link/rank-only apply does not take the write lock and does not run the conflict pre-check. Other users see no lock overlay while it runs. Note also that rankIssue is the one write resolver with no plan-role gate — link creation, link deletion and lag all call requireEdit, rank does not.

45When a write fails partway

There is no rollback: issues already written stay written. What the app does instead is keep the rest recoverable and never lie about success.

CarefulAn apply is not a transaction. If it fails on issue 40 of 60, the first 39 are already in Jira and stay there. Nothing is rolled back. What the app guarantees is that you can see exactly what failed and retry it.

Failure modes and what each one does

FailureWhat the user seesWhat happens to the work
A single issue's PUT is rejected (permission, validation, unknown field)The failure is counted and listed in the error box: issue key in red plus Jira's message, first 6 shown then "…and N more". The run continues to the next issue.Written issues stay written; the failed issue is untouched in Jira
The write lock was lost mid-run (expired, or taken over)Phase flips to "Apply failed", toast "Write chunk failed"The run stops before the next chunk rather than writing on top of another writer, and the front end calls cancelWrite so the lock is released and the plan status returns to "indexed". Already-written issues stay written.
Verification finds Jira did not store what was sentPhase "Apply failed", toast "Some changes did not persist in Jira — retry", with Close and RetryDeliberately: no re-index (so plan storage keeps your intent instead of being clobbered by the stale Jira value), the plan version is NOT bumped, your draft is KEPT, the lock is released and status returns to "indexed". The failing keys are reported.
A link, unlink or rank move is refusedToast: "N link/rank changes couldn't be written to Jira — still staged, Apply again to retry"Exactly the items Jira refused stay staged. They are not silently dropped.
You press Cancel during Starting or WritingThe modal closescancelWrite releases the lock and sets the plan status back to "indexed". Anything already written stays written.

Retry semantics

The Retry button re-runs the whole sequence from the beginning: it re-acquires the lock and re-writes every issue in the change set, including ones that already succeeded. Re-writing the same date values is harmless. Note that when a run ends in the error phase, the plan is NOT reloaded — your edited timeline stays exactly as it was on screen, which is what makes an immediate retry possible.

NoteFailed link and rank items staying staged was a deliberate fix. Previously a refused createDependency — a link type the instance does not have, a permission denial — produced one warning toast and then the user's connections were gone, with no way to retry and nothing left to show what was lost.

After a mixed run

When the date pass completes but some links or ranks failed, the toast reads "N link/rank changes couldn't be written to Jira — still staged, Apply again to retry". (The differently worded "Applied N · M couldn't be written to Jira — still staged, Apply again to retry" belongs to the link/rank-only path, which has no date pass to report.) Either way the plan then reloads: successfully written issues come back with a fresh baseline (so they no longer count as changes), and anything still staged still counts, so the Apply button reappears with just the remainder.

46The write lock and what other people see

One writer at a time per plan, for a maximum of five minutes, with a live progress bar and an auto-unlock countdown for everyone else.

A date apply takes an exclusive write lock on the plan before touching Jira, so two people cannot write over each other. The lock record holds the holder's account id, their display name resolved from Jira, a timestamp, a unique token and an expiry.

Lock behaviour

PropertyValue / behaviour
ScopeOne lock per plan, stored at p:{planId}:lock
TTL5 minutes from acquisition
RenewalRefreshed after every chunk of 10 issues
EnforcementEvery chunk verifies you are still the holder before writing; if not, the run aborts
ExpiryAn expired lock can be taken over by the next writer — an abandoned write cannot freeze a plan permanently
ReleaseOn completion, on cancel, on a verification failure, on a detected conflict, and on an unexpected error
ContentionA second writer is refused with "Plan is locked by <name>"

What the other user sees

Anyone else on the plan gets a dimmed, blurred overlay across the plan content area with a lock icon and the heading "Plan is being written", then: "<name> is writing changes to Jira tickets. Editing is temporarily disabled." Below it is a progress bar. If write progress events are arriving it is a real bar with "12 of 60 written · 20%"; otherwise it is an indeterminate bar. Underneath, a live countdown ticking every second: "Plan unlocks automatically in 4:12", then "Unlocking…". A Refresh button re-polls the lock and draft state on demand. The overlay never renders for the lock holder themselves.

NoteThe countdown exists so a stale or abandoned lock never reads as an indefinite freeze — the wait is always bounded by the 5-minute TTL, whatever happened to the writer's browser. The awareness endpoint only returns a lock that is still live, so an expired one simply stops appearing.
LimitThe lock is advisory and applies to this app only. It does not lock anything in Jira. Someone editing the same ticket directly in Jira, or an automation rule, is completely unaffected — that class of collision is what conflict detection is for.
LimitBecause Forge storage has no compare-and-set, acquisition writes a unique token and then re-reads to confirm it won. This collapses a simultaneous double-acquire to a single winner in practice, but it is not a true atomic lock. Combined with per-chunk holder verification, the realistic worst case is an aborted run, not interleaved writes.

Side effects on the plan while writing

The plan's status is set to "writing" for the duration. The hourly background refresh skips any plan whose status is "writing" or "indexing", so a scheduled re-index cannot land in the middle of your apply.

47Conflict detection — when an issue changed in Jira while you were planning

Two separate checks with different scopes: a local pre-check that can be over-eager, and a live Jira comparison at write time that aborts the whole apply.

Both checks look at the same four fields — Start Date, Due Date, Duration, Buffer. Changes to summary, status, assignee or anything else are ignored entirely and never block an apply.

The two checks

Pre-check (before the write starts)Live check (inside the first write chunk)
What it comparesThe values stored in the plan against the Jira baseline recorded at index timeThe issue's CURRENT values in Jira, re-fetched live, against that same baseline
ScopeOnly issues named in your draftEvery issue in the change set you confirmed
Contacts Jira?NoYes — a live bulk fetch
On a hitOpens the Conflicts Detected dialog; nothing has been writtenReleases the lock and aborts the entire apply before writing a single field
Runs when there is no draftReturns "no conflicts" and the apply proceedsAlways runs

The Conflicts Detected dialog

Headline: "N issues have been modified by other users since you started editing." Each row shows the issue key, the field label, and the old value struck through with an arrow to the current value in red. Empty values render as "(empty)", and anything that looks like a date is formatted short. You must tick "I understand that my changes may override the external modifications listed above" before the Apply Anyway button becomes usable.

Your three options

Cancel
Closes the dialog. Nothing is written, everything stays staged. Escape does the same.
Re-index Plan
Runs the same re-index as the toolbar button: fresh Jira data for the whole plan, with your unsaved edits re-overlaid from your draft on top. This is the option to take when you want to see the other person's change before deciding.
Apply Anyway
Proceeds with the write. Your values overwrite whatever is currently in Jira for the fields you are writing.
CarefulThe pre-check can flag your own work. It compares the value stored in the plan against the Jira baseline on the same stored row, so an issue you saved earlier but never applied already differs from that baseline. Edit an issue, let it autosave, then edit it again, and the pre-check will list it as "modified by other users" when you apply. The live check inside the write is the authoritative one — it is the only test that actually looks at Jira.
LimitThe live check is all-or-nothing. One externally-changed issue anywhere in the change set aborts the whole apply, not just that row. The backend returns "Some issues changed in Jira since you started editing — refresh and retry"; the front end surfaces this as a generic "Write chunk failed" toast with the run in its error state. Nothing was written.
LimitIf a conflict is reported for an apply that contains no date changes at all (only links or rank moves), "Apply Anyway" has nothing to drive — the write modal only renders when there is at least one date change. Your staged links and rank moves stay staged; close the dialog and apply again.

48Live presence, live updates, and the polling fallback

Who else is on the plan, how changes from others reach your screen, and the honest Live / Polling indicator when the live channel is unavailable.

At the left of the toolbar's action group — ahead of the drafts badge, Save, Delete, Re-index and Apply — sit two things: a connection pill and a cluster of avatars. Both are read-only awareness; neither changes any data.

The connection pill

LabelDotMeans
LiveSolid green, pulsingSubscribed to the plan's live channel — changes from others appear within about a second
ConnectingGrey, pulsingSubscribing (also the state shown for any unrecognised status)
PollingSolid amber, steadyThe live channel is unavailable; updates arrive on the 60-second timer instead
Note"Live" is never shown unless an active subscription is actually held, so the indicator cannot lie green. Reconnection uses exponential backoff capped at 30 seconds with 50–100% jitter, and on every successful (re)subscribe a synthetic event is dispatched that forces one drafts/lock poll, closing the gap of anything missed while disconnected. There are two delivery planes on the same channel; only the primary one drives the pill, so a tenant without the global plane degrades silently rather than showing Polling.

What each live event does to your screen

EventEffect
draft:updated / draft:deletedRe-poll the drafts banner and badge
lock:acquired / lock:releasedRe-poll — this is what raises and drops the "Plan is being written" overlay
write:progressFills the progress bar inside that overlay (one event per chunk of 10, never per issue)
presence:beat / presence:leaveRefresh the avatar cluster only
plan:versionSomeone applied changes, or Jira changed externally — see the non-destructive rule below. If your own account id has not resolved yet, the event only triggers a re-poll, so a replay of your own write can never toast at you.
index:done / index:errorA background (re)index finished or failed; the plan meta reloads, and an error is surfaced as a toast for 7 seconds
TipHard rule, applied to both remote writes and background re-indexes: a remote change is a notification, never an auto-stomp. If you have any local edits, the app keeps them and only tells you — "<name> applied changes to this plan. Your edits are kept — reload when you're ready to merge." (or, for an out-of-app change, "This plan changed in Jira. Your edits are kept — reload when you're ready to merge.", and for a re-index "This plan was re-indexed — your unsaved edits are kept."). Only when you have nothing local to lose does it converge on the fresh data automatically. The same rule governs switching between the Gantt, Table and Dashboard tabs: a tab switch will not pull newer server data over your edits.

Presence

Your client sends a heartbeat every 30 seconds, carrying which view you are on, plus an extra beat immediately when you switch views. The server prunes anyone whose last beat is more than 90 seconds old. Leaving is sent on both pagehide and beforeunload so a closed tab drops out promptly rather than lingering for the TTL, and returning to a backgrounded tab re-beats immediately. The avatar cluster shows up to 5 people, most recently active first, and then "+N"; hovering names each one, marks yourself "(you)" and shows the view they are on. Each person gets a ring colour derived deterministically from their account id, so the same person is the same colour for everyone, across sessions. The cluster is hidden when you are the only person there.

LimitEverything here degrades to a 60-second poll of a single awareness endpoint (getNotifications), which returns other users' drafts and the current lock. Live updates are strictly additive for awareness: with the live channel down, the drafts banner and the lock overlay still work off the poll, just up to a minute late. Remote-write notices do not degrade — the "<name> applied changes…" and "This plan changed in Jira…" toasts are raised only by the live plan:version event, and the poll carries no version information, so in Polling mode a remote write goes unannounced; the view only converges on the new data when you next switch tabs with nothing locally edited.
NoteYou never react to your own events: an event carrying your own account id is dropped before anything else happens. The live channel also replays the last ~120 seconds on every (re)subscribe, so a completed re-index is deliberately de-duplicated by its version number — only a genuinely newer index, which always bumps the version, is acted on again. Index errors carry no version and are never coalesced, so every failure surfaces.

49Plan protection, and how to undo an apply

The one place the app writes to Jira without you asking — and the honest answer that there is no undo button.

Plan protection

Plan protection is the only path where the app changes a Jira issue without anyone pressing Apply. It runs from the Forge trigger on avi:jira:updated:issue and exists to stop ad-hoc ticket edits from breaking a plan's dependency structure.

What it checks, in order

  1. 1The update must include a date field change; anything else is ignored immediately.
  2. 2The issue must belong to a plan that has protection enabled.
  3. 3The issue must have at least one predecessor. Unblocked issues are never protected.
  4. 4The changelog must contain a START date change.
  5. 5Required start is computed as the maximum, over all predecessors, of the lag-aware next working day after that predecessor's due date — the same rule the scheduling engine uses, so a legitimately lagged link is not punished for landing later than bare adjacency.
  6. 6If the new start is earlier than that required start, the change is reverted and a comment is posted.

What the revert does

It writes the previous start date back (and the previous due date too, if the due date was changed in the same edit) and posts a plain-text comment on the issue headed "⚠️ LeanZero Management: Date change reverted". The comment names the blocking issues, the latest predecessor due date, the earliest allowed start date, the start date that was requested, a one-line statement of the Iron Clad Rule, and the plan name — then points the user at the plan view, where changing the date will cascade to dependents properly instead of being rejected. Both the revert and the comment are made as the app, but only the revert suppresses notifications (the write goes out with notifyUsers=false). The comment is posted as an ordinary comment — the comment endpoint has no suppression parameter — so Jira's normal comment notifications to watchers still go out, which is in practice how the person whose edit was reverted finds out.

LimitProtection is enabled by default on every plan created through the wizard (createPlan sets it true unless the caller explicitly passes false, and the wizard never passes it) and there is no toggle for it anywhere in the UI in this build — it can only be changed through the updatePlan resolver, which itself carries no plan-role gate. Other limits worth knowing: plans are scanned in order and the FIRST violation found stops the scan, so an issue in several protected plans is reverted once against whichever plan objects first; moving dates LATER is never reverted; a due-date-only change is never reverted; the revert writes the start date back to the start field the app is CONFIGURED to use (config.fields.startDate, resolved lazily only once a protected plan actually contains the issue, with customfield_10015 as the fallback when nothing is configured); and the required start is computed from the GLOBAL admin calendar and holiday table (cfg:working-days / cfg:holidays), not from the plan's own schedule, so a plan whose Schedule tab differs from the global calendar can see the guard revert an edit the timeline itself would allow. One residual gap in recognition: the cheap changelog pre-filter matches date changes by field NAME (or the default id), so a custom start field whose display name contains neither "start date" nor "due date" AND whose id differs from the default is not recognised by the guard at all.
NoteWhen an issue update is not a violation, the trigger instead does a lightweight incremental sync of that issue into plan storage — which is how out-of-app Jira edits find their way into the plan between re-indexes.

Undoing an apply

There is no undo. Nothing in the app stores a pre-apply snapshot of Jira values that can be restored, there is no revert or undo resolver anywhere in the API surface, and no revert path in the write flow. The moment an apply completes, the re-index makes the written values the new baseline for the plan.

What to do instead

  • Before applying: Discard All in the Review Changes dialog is your real undo. It reverts the view, clears every staged link, unlink and rank move, and persists the revert.
  • After applying: recover the previous values from Jira's own issue history (the change log on each ticket shows the app's write and the value it replaced), then set them back in the plan and apply again. Re-index first so you are editing against current Jira data.
  • Do not rely on a baseline snapshot for this. A baseline freezes planned start, due and duration into a separate key purely so the timeline can draw ghost bars for variance — it is display-only, it never enters the write path, there is no restore-from-baseline action (only get, set and clear), and it is capped at 2000 issues.
  • For links and rank moves there is no history to recover from beyond Jira's own issue activity: re-create the link or drag the row back, then apply.
CarefulBecause writes are made as the app with notifications suppressed, an unwanted apply is quiet — nobody gets an email about it. If a plan is shared, say so in the plan rather than assuming the write announced itself. Other people who have the plan open do get a toast at the time — "<name> applied changes to this plan. Your edits are kept — reload when you're ready to merge." if they have local edits, or "<name> applied changes — refreshing to the latest." if they don't — but only while they are looking at it.

50Roles, the capability matrix, and how a role is decided

The five roles LeanZero Management recognises, exactly what each one can do, and the precise order in which the app decides which role you have for a given plan.

Access is decided per plan, not per project. Every plan carries its own owner, its own member list and its own default access level, all stored in the plan's metadata record (p:<planId>:meta). There is no global "can use the app" role beyond Jira's own app access.

Capability matrix

RoleView planEdit dates / save / applyDelete planManage members and visibility
admin (Jira site/org/app admin, or a plan member given the Admin role)YesYesYesYes
owner (the account that created the plan)YesYesYesYes
editorYesYesNoNo
viewerYesNoNoNo
noneNo — the plan is not even listedNoNoNo

The four capability predicates, verbatim

canView(role) = role !== 'none'. canEdit(role) = admin, owner or editor. canDelete(role) = admin or owner. canManagePermissions(role) = admin or owner. There is no separate "apply to Jira" capability — applying is gated by canEdit, the same predicate as editing dates in the timeline.

Role resolution order (resolveRole), first match wins

  1. 1Jira site/org/app administrator → admin. Determined by GET /rest/api/3/mypermissions?permissions=ADMINISTER called as the user; any error returns false, so the check is fail-closed.
  2. 2Your accountId equals the plan's createdBy → owner. The comparison is guarded so that a plan whose createdBy was lost (undefined) plus a caller with no accountId does not accidentally resolve to owner.
  3. 3You appear in the plan's members array → the role recorded on that member entry (viewer, editor or admin).
  4. 4You appear in the legacy editors array (plans created before the members model) → editor.
  5. 5Otherwise the plan's defaultAccess value is your role — none, viewer, editor or admin.
  6. 6Legacy plans with no defaultAccess field at all: viewer if they have an editors array, otherwise none.

Plan visibility (defaultAccess) — the dropdown on the plan's Permissions tab and on Settings → Plan Permissions

Stored valueLabel in the UIEffect on everyone who is not the owner or a listed member
none (default for new plans)Private — only invited membersNo access at all; the plan does not appear in their plan list
viewerOpen to all — everyone can viewRead-only access to the Gantt, the table and the dashboard
editorOpen to all — everyone can editCan edit dates, save the plan and apply changes to Jira
adminOpen to all — everyone can administerFull access, including deleting the plan and changing its permissions

Where a new plan's visibility comes from

The plan creation wizard's third step, "Visibility", offers the same four options and defaults to Private. Whatever you pick is sent to createPlan as defaultAccess; if the field is absent the resolver stores 'none'. New plans always start with an empty members array, but whether the owner is the only person with access depends on the visibility you picked. Private (the default) means exactly that — nobody else even sees the plan until you add someone, apart from a Jira site admin, who always resolves to admin. Pick any of the "Everyone can…" options and every user who can open the app holds that role from the moment of creation, with no member added: a plan created as "Everyone can edit" is editable — and appliable to Jira — by the whole site immediately.

Careful"Open to all — everyone can administer" grants delete and permission-management rights to every user who can open the app. There is no confirmation step on that dropdown; the change saves immediately and shows a "Visibility updated" toast.

Managing members

Where
Two places, both backed by the same resolvers: the plan's own Permissions view (open a plan, then Permissions), and Settings → Plan Permissions, which lists every plan you can see as an expandable card. Each card shows the plan name, a visibility badge (Private / Open — View / Open — Edit / Open — Full Access), the owner, the member count and the issue count.
Adding someone
Type at least 2 characters into "Search users to add...". The search is debounced by 300 ms and calls GET /rest/api/3/user/search with maxResults=10, so at most 10 Jira users come back. The owner and anyone already a member are filtered out of the results. Clicking a result adds them as viewer — always viewer, regardless of where you added them from — and you then change the role in the dropdown next to their name.
Valid member roles
viewer, editor, admin. The resolver rejects anything else with "Invalid role. Must be one of: viewer, editor, admin". owner is not assignable; it is derived from createdBy and cannot be transferred through the UI.
Adding the owner
Rejected with "The plan owner already has full access".
Adding an existing member
A silent no-op — the resolver returns the unchanged member list rather than duplicating the entry.
Removing
The red trash button next to a member removes them immediately, with no confirmation dialog. They fall back to whatever the plan's defaultAccess grants.
NoteThe role badge in the top-right of the plan's Permissions view tells you what the app thinks you are right now: Admin (purple #7c3aed), Owner (green #16a34a), Editor (blue #2563eb), Viewer (slate #64748b) or No Access (red #dc2626). If you expected Editor and see Viewer, check the member list first and the visibility dropdown second.

51Where permissions are actually enforced — and where they are not

Which resolvers re-check your role on the server, which rely only on the UI hiding a button, and what that means in practice.

The front end asks the checkUserRole resolver once per plan and uses the answer to hide two things and two things only: the Delete action on the plan header (canDelete) and the member/visibility controls on the Permissions view (canManagePermissions). The canEdit flag the resolver returns is not consumed anywhere in the UI — the Apply button stays visible to a viewer. That is fine, because the real gate is server-side: startWrite refuses and the Apply dialog surfaces the error as a toast. The important question is which resolvers re-check the caller server-side. Several do; several do not. Both lists are below, honestly.

The server-side gate

requireCapability(planId, accountId, capFn) loads the plan meta, resolves your role WITHOUT the site-admin lookup, and returns immediately if that role already satisfies the capability. Only when the base role is insufficient does it pay for the extra Jira ADMINISTER round-trip and retry as admin. That ordering is a deliberate cost optimisation — owners, editors and members never trigger the extra call. It fails early with 'planId is required' or 'Plan not found'. Two shorthands are exported: requireEdit and requireDelete.

Resolvers that DO enforce a role

ResolverGateEffect of failing
deletePlanrequireDelete (admin/owner){ success:false, error:'You do not have permission to perform this action' }
startWrite (acquires the Apply lock)requireEditApply cannot start; the dialog toasts the error. Because writeChunk verifies the caller still holds the lock, a caller who cannot acquire it cannot write to Jira either
createDependency, deleteDependencyrequireEditLink is not created/removed in Jira
setLinkLagrequireEditThe per-link lag is not stored. (A negative lag is rejected before the gate with 'Lead (negative lag) is not supported yet')
rankIssuerequireEditThe rank write never reaches Jira (it goes out as the app via PUT /rest/agile/1.0/issue/rank, so it is gated like its sibling dependency resolvers; planId is required and the resolver fails closed without it)
recalculatePlan, recalculateFromIssuerequireEditNo recalculation
addMember, removeMember, updateMemberRole, updatePlanAccesscanManagePermissions (admin/owner)'Permission denied'
listPlanscanView filterPlans you cannot view are silently omitted from the list
Every mutating admin resolverJira ADMINISTER (see the admin section)'Jira administrator permission is required for this action'

Mutating resolvers with NO role check

ResolverWhat it changes
updatePlanPlan name, sources, calendarKey, milestones, protection flag, includeParents
updateIssueDates, savePlanStateThe plan's stored dates/durations/buffer flags in Forge storage (not Jira)
savePlanSchedule, addPlanHoliday, removePlanHolidayThe plan's working-day calendar and holiday list
indexPlanQueues a full re-index, which rewrites every issue shard of the plan and flips its status to 'queued'
refreshRanksRe-fetches ranks from Jira and patches them into the plan's shards
setAiConfigThe global AI kill switch and the monthly/daily call caps
setBaseline, clearBaselineThe plan's baseline snapshot
saveDraft, deleteDraftYour own per-user draft and the plan's drafts registry
LimitReads are ungated too. listPlans filters by visibility, but getPlan, getIssues, getAllIssues, getIssue, getPlanSchedule and getPlanCalendar do not — anyone who knows a plan ID can read that plan's stored data. previewOrphanedPlans is also ungated, and it enumerates every key in storage. Treat plan visibility as an organising control for a trusted team, not as a confidentiality boundary against a determined user who already has app access.
NoteThe one place this genuinely matters day to day: a viewer cannot push anything to Jira (startWrite is gated, and so is rankIssue, the one resolver that reorders Jira immediately without an Apply step) and cannot delete the plan, but a viewer CAN change the plan's calendar and holidays, re-index it, and cause the app's own stored copy of the schedule to change. Jira itself stays untouched until someone with edit rights applies.

52The admin page and the Field Mapping tab

How to reach the global settings, how mutating admin actions are gated, and the six Jira fields the app maps — including what happens when a field is missing or not on a project's edit screen.

The settings screen is a Jira admin page module (manifest key ppm-admin-settings) titled "LeanZero Management Settings". It has five tabs in this order: Calculation Engine, Field Mapping, Display, Plan Permissions, Maintenance. The rule that trips people up most is stated twice — in the hero subtitle and again in a banner at the foot of the page: these are global settings shared by every plan, while working days and bank holidays are managed per plan from the plan's Schedule tab.

How admin actions are gated

Every mutating admin resolver is wrapped in an adminOnly helper that calls checkIsAdmin and refuses with "Jira administrator permission is required for this action" if the caller does not have Jira ADMINISTER. That covers saveFieldConfig, saveEngineConfig, saveWorkingDaysConfig, saveHolidays, addHoliday, removeHoliday, detectFields, cleanupOrphanedPlans and purgeAllData. The code says why: the UI only renders the admin page for admins, but resolvers are callable directly, so without this gate any user with app access could rewrite the field mapping, the engine limits, the calendar and the holiday table, or trigger the two destructive maintenance actions. checkIsAdmin is fail-closed, so a Jira hiccup denies rather than allows. Reads (getFieldConfig, getEngineConfig, getWorkingDaysConfig, getHolidays, getFullConfig, previewOrphanedPlans) are deliberately left ungated — they expose only configuration the admin screen already shows, and gating them would add a Jira round-trip to every settings load.

Field Mapping — the six mapped fields and their defaults

FieldLabel in the UIDefault IDWhat the app does with it
startDateStart Datecustomfield_10015Read at index time; written back on Apply; one of the four fields watched for external conflicts
dueDateDue DateduedateStandard Jira field; read at index time, written back on Apply; conflict-watched
durationDurationcustomfield_11581Working-days duration; read at index time (parsed as a number), written back on Apply; conflict-watched
bufferBuffercustomfield_12399Buffer status; parsed to "Yes"/"No"; written back as { value: … }; conflict-watched
bufferPercentageBuffer %customfield_12421Nothing — see the gotcha below
rankRankcustomfield_10019Jira Software LexoRank; READ ONLY through this mapping — it is fetched at index time and drives Gantt row order. Rank changes are written back through the Agile API (PUT /rest/agile/1.0/issue/rank), which does not use this field ID
LimitbufferPercentage is presented as a configurable field but no code reads or writes it. It appears in the defaults, in the FIELD_DEFAULTS constant, in the Field Mapping tab, and as a null in what auto-detection writes — nowhere else. Changing it has no effect.

How a mapped ID is resolved at runtime

Saved overrides are merged over the built-in defaults on every load (loadFieldConfig returns { ...DEFAULTS.fields, ...saved }). Consumers then apply a second fallback: e.g. the indexer requests fieldConfig.startDate || 'customfield_10015'. The practical consequence is that blanking a box in the Field Mapping tab and saving does not break the app — an empty string is falsy, so the built-in default is used instead. A wrong-but-non-empty ID does break it, silently: the field simply reads as null on every issue.

Buttons on the Field Mapping tab

custom badge
An amber "custom" pill appears to the left of any input whose value differs from the built-in default. Fields at their default render the same badge with visibility:hidden, so every input's right edge stays aligned.
Reset to Defaults
Repopulates all six boxes with the built-in defaults and marks the form dirty. Nothing is stored until you press Save Changes.
Save Changes
Disabled until something changes. Writes { ...defaults, ...your values } to the cfg:fields storage key and toasts "Field configuration saved".

Automatic field detection

A detectFields action exists on the back end (admin-gated). It fetches every field from GET /rest/api/3/field, then: Start Date is matched by the names "Start date"/"Start Date" (preferring a custom field over a system field) and falls back to customfield_10015; Due Date is always the system duedate; Duration is matched by "Duration"/"PPM Duration" and, if absent, is CREATED as a float (number) custom field named "PPM Duration"; Buffer is matched by "Buffer"/"PPM Buffer" and, if absent, is CREATED as a select field named "PPM Buffer". It then tries to add Start Date, Duration and Buffer to edit screens.

CarefulThree things about detection. First, no button in the current admin UI invokes it — it runs only if something calls the detectFields resolver directly. Second, the resolver calls detectAndSetupFields() with NO project keys, so the per-project walk (project → issue type screen scheme → screen scheme → edit screen → first tab) never actually runs as wired; only the fallback fires, which lists screens via GET /rest/api/3/screens?maxResult=50 and picks the one literally named "Default Screen", or the first screen returned if there is none. Third, the config it writes contains only startDate, dueDate, duration, buffer and a null bufferPercentage — it has no rank key, so running detection discards any custom Rank field ID you had configured and the app reverts to customfield_10019.

What happens when a field is missing or not settable on a project

On Apply, before writing a chunk, the app calls GET /rest/api/3/issue/{key}/editmeta for the first issue of the chunk and keeps only the fields that endpoint reports as settable. The reason is spelled out in the code: a custom field a project never configured (commonly PPM Duration or PPM Buffer on an imported plan) makes Jira reject the ENTIRE PUT with "Field '…' cannot be set. It is not on the appropriate screen, or unknown" — which would silently kill the date write as well. Fields that are not settable are dropped; if a change touches only unsupported fields, the write becomes a no-op rather than a failure. If editmeta cannot be read at all (non-OK response or a thrown error), it returns null and the app falls back to attempting every field.

LimitThe settable-field probe is run once per chunk using the FIRST issue in that chunk (WRITE_CHUNK_SIZE = 10 issues). A plan spanning projects with different screen configurations can therefore have durations dropped for issues that would have accepted them, or attempted for issues that will not. If duration or buffer values are not landing in Jira for part of a plan, check that project's edit screen before suspecting the engine.

53The Calculation Engine tab, setting by setting

Every field on the engine tab with its default, its allowed range, what reads it, and which settings are currently inert.

Four grouped cards: Issue Link Dependencies, Chain Calculation Limits, Buffer Task Settings, and Plan Indexing & Storage. Any value that differs from its default is tagged with an amber "modified" badge next to the label. Reset to Defaults refills the form with the eight defaults below; Save Changes (disabled until dirty) merges your values over the defaults and the stored config into the cfg:engine key and toasts "Engine configuration saved".

Every engine setting

SettingUI labelDefaultAllowed range in the inputRead by
dependencies.linkTypeNameIssue Link Type NameBlocksfree textThe indexer's issue transformer, when extracting predecessors/successors, and createDependency / deleteDependency when writing links to Jira
engine.maxCascadeDepthMax Cascade Depth103–50The back-end chain calculator, in both its cascade passes (falls back to 10 if unset)
engine.maxParentIterationsMax Parent Roll-up Passes51–20The back-end engine's roll-up loop (falls back to 5)
engine.maxTraversalDepthMax Dependency Graph Depth155–50Nothing — see the gotcha
engine.maxIssuesPerTraversalMax Issues in Single Calculation15050–5000Nothing — see the gotcha
buffer.impactPrefixBuffer Impact Child Prefix[BUFFER IMPACT]free textThe back-end engine, passed into the calculation context as bufferImpactPrefix and consumed by the parent roll-up
indexing.maxSummaryLengthIssue Summary Truncation8040–200The indexer's issue transformer, which truncates each summary to this length
indexing.issuesPerShardIssues per Storage Shard10050–200Nothing — sharding uses a hard-coded constant

Why each limit exists (from the tab's own help text)

Cascade depth bounds how far a due-date change is pushed forward through successors — A blocks B blocks C blocks D is a depth of 3, so the default of 10 covers chains up to ten levels. Parent roll-up passes exist because a parent takes its start from the earliest child start and its due from the latest child due; when several children move at once the parent may need more than one pass to settle. Summary truncation exists to save storage — the full summary is always one click away in Jira. Shard size exists because each Forge storage entry is capped at 240 KB; the tab's help text puts 100 issues per shard at roughly 30 KB.

LimitThree of the settings on this tab do nothing today. maxTraversalDepth and maxIssuesPerTraversal are stored and displayed but no code reads them. issuesPerShard is likewise unread — sharding is governed by the SHARD_SIZE = 100 constant in kvs-keys.js. Changing any of the three has no effect on behaviour. A fourth stored default, indexing.batchSize (5), is neither shown on the tab nor read anywhere.
CarefulBigger caveat: the limits that ARE read belong to the BACK-END engine, and no screen in the app invokes it — nothing in the front end calls recalculatePlan or recalculateFromIssue. Interactive editing — dragging a bar, changing a duration, the preview you see before Apply — is computed in the browser by the front-end cascade, which reads none of these settings and bounds itself with its own sweep limit (number of issues + 2). In practice, Max Cascade Depth and Max Parent Roll-up Passes will not change what you see in the timeline. Issue Link Type Name and Issue Summary Truncation, by contrast, are genuinely load-bearing: they shape what indexing puts into the plan.
TipIssue Link Type Name must match your Jira link type EXACTLY, including case — the indexer compares link.type.name with a strict !== and skips anything else. If your instance calls dependencies "Depends on" rather than "Blocks", set it here and re-index every plan; links indexed under the old name will not appear as dependencies. Two related values, inwardDescription ("is blocked by") and outwardDescription ("blocks"), exist in the stored config but are not exposed in the UI and are not read by any code.

Buffer tasks, in one paragraph

A buffer task absorbs delay: when a predecessor pushes its start forward, the buffer holds its due date and shrinks its duration instead, so successors are protected. The impactPrefix setting does NOT create anything — it is the prefix the engine uses to RECOGNISE buffer-impact tracking issues. Any child whose summary starts with it (for example "[BUFFER IMPACT] Sprint 5 delay absorbed") is excluded from the buffer parent's date roll-up, so a tracking issue you keep under the parent does not distort the parent's rolled-up dates. The roll-up falls back to the literal '[BUFFER IMPACT]' if no prefix is supplied. A further default, buffer.enableBufferTracking (true), exists in the config but is not exposed in the UI and is not read.

54The Display tab

The one per-browser preference on the admin page and why it is not a global setting.

The Display tab sits inside the admin page but is not an admin setting. Its single card, "Gantt Display", holds preferences stored in the browser's localStorage, so each user toggles them for themselves and nobody else is affected. The card says as much: "Per-browser preferences for how the Gantt timeline is drawn. These settings only affect your view."

Settings

Parent rollup guides
Default ON. Shows vertical lines from each parent bracket down to the descendants whose start and due dates drive the parent's rolled-up dates. Stored under the localStorage key ppm.display.parentGuides as the string 'true' or 'false'.
NotePreference changes propagate immediately to other components in the same window (via a custom 'ppm-display-pref' event) and to other browser tabs (via the storage event). They do not follow the user to another browser or another machine, and a Jira admin cannot set them on someone's behalf. If localStorage is unavailable — private browsing, quota exhausted — reads fall back to the default and the write is silently skipped.

55Working-day calendars and holidays

Where calendars actually live (per plan, not global), the presets, holiday management, the exact date arithmetic, and the two configuration traps in this area.

Calendars are per plan. Open a plan and go to its Schedule view; it is titled "Plan Schedule", has two sub-tabs — Working Days and Bank Holidays — and states "Working days and holidays for this plan. Changes only affect this plan." The active calendar's name is shown as a badge in the top right, and the Bank Holidays tab carries a count chip. Everything is stored under the plan's own key, p:<planId>:sched, as { workingDays, calendarName, holidays }.

Working-day presets on the plan's Schedule tab

PresetDays stored (0 = Sunday)Notes
Standard (Mon-Fri)1,2,3,4,5The default returned for any plan that has no sched key yet
Israel (Sun-Thu)0,1,2,3,4
UAE (Mon-Fri + Sat half)1,2,3,4,5Identical to Standard — the half day is not modelled
6-Day (Mon-Sat)1,2,3,4,5,6

Applying a preset

Clicking a preset card saves immediately (no confirm step) and toasts Calendar set to "<name>". The active card is marked with a green "Active" badge, matched by calendarName, and each card shows seven day chips with the working ones filled in.

Custom calendars

"+ Create Custom Calendar" opens a form with a free-text name (placeholder "e.g., Saudi Arabia (Sun-Thu)") and seven day toggles, pre-selected Mon–Fri. Apply Custom Calendar is disabled until the name is non-empty and at least one day is selected. The custom calendar is stored on the plan as a plain sorted day list plus its name; it is not added to any shared library, so it must be recreated for each plan that needs it.

Bank holidays (per plan)

Adding
Pick a date in the custom date picker, optionally type a name ("Christmas"), then press Add — or hit Enter while in the name box. Add is disabled until a date is picked. The resolver rejects anything that is not YYYY-MM-DD with "Date must be YYYY-MM-DD".
Duplicates
Adding a date that already exists is a silent no-op — the existing entry, including its name, is kept.
Ordering
The list is re-sorted ascending by date on every add.
Year filter
Four buttons: last year, this year, next year, the year after. The list shows only holidays whose date string starts with the selected year; an empty year shows "No holidays for <year>".
The footer count
Always shows "N holiday(s) total". The "(M in <year>)" suffix appears only when the filtered count differs from the total.
Removing
The trash button removes the holiday immediately — no confirmation.

The date arithmetic these settings drive

A day is a working day when its UTC day-of-week is in the calendar's day set AND its YYYY-MM-DD string is not in the holiday set. All arithmetic is UTC to avoid timezone drift. Duration is 1-indexed and inclusive: duration 1 means due = start.

The formulas

CalculationFormula
Due from start + durationduration = 1 → due = start; otherwise due = addWorkingDays(start, duration − 1)
Start from due + durationduration = 1 → start = due; otherwise start = subtractWorkingDays(due, duration − 1), which finishes by snapping forward to the next working day
Duration from start and duemax(1, workingDaysBetween(start, due) + 1), where the between-count is exclusive of the end date; returns 0 if either date is missing or start is after due
Successor start from a predecessorthe next working day on or after (predecessor due + 1 calendar day)
Successor start with a finish-to-start lag of N working daysthat same base, advanced N further working days. A lag of 0 or less is treated as no lag by the calculator, and setLinkLag rejects a negative lag outright with 'Lead (negative lag) is not supported yet'
CarefulThe calendar you choose in the plan creation wizard does not reach the scheduler. The wizard reads the global calendar list (getWorkingDaysConfig, pre-selecting the global active calendar) and stores your choice as calendarKey on the plan's metadata, but the working-day context used for calculations is read by the plan view from p:<planId>:sched via getPlanCalendar, which a brand-new plan does not have — so it falls back to Standard Mon–Fri with no holidays. No code reads meta.calendarKey for any calculation. After creating a plan, set its calendar again on the plan's Schedule tab, or every date will be computed Mon–Fri.
NoteThere is also a global calendar configuration (storage key cfg:working-days) with an active calendar and two built-ins: standard (Mon–Fri) and israel (Sun–Thu). It has three consumers today. It populates the calendar choices in the plan creation wizard. It feeds the BACK-END engine, which builds its working-day context from the global config and the global holiday table rather than from the plan's schedule — one more reason the back-end engine's results would not match what you see in the timeline. And — the one with live effect — it feeds Plan Protection: the issue-updated guard computes the required successor start from this global calendar and the global holiday table, not from the plan's schedule, when deciding whether to revert an out-of-app date edit. A parallel global holiday table (cfg:holidays:<year>, loaded for the current and next year only, and marked legacy in the key schema) exists with add/remove/save resolvers, all admin-gated, but no screen in the app edits it — yet the protection guard reads it on every decision. Manage holidays per plan for the timeline, but keep the global calendar in mind whenever protection is on: a plan whose Schedule tab differs from it can see the guard revert an edit the timeline itself would allow.
LimitTwo safety limits inside the date helpers are worth knowing. Finding the next working day gives up after 14 consecutive non-working days and returns the original date unchanged — a calendar with fewer than one working day in any fortnight will produce dates on non-working days. Adding or subtracting N working days is bounded at N × 3 + 60 iterations.

56AI features: what they do, what they cost, and how to switch them off

The three optional AI features, the model behind them, the caps, cache and meter that bound spend, and the guarantee that none of it changes your schedule.

NoteAll AI features are OFF by default and are controlled by one toggle on Settings → Maintenance, under the heading "AI Plan Review" with an "optional" pill. While the toggle is off, every AI resolver returns { enabled: false } before contacting any model. The UI is only partly hidden, though: the Apply dialog's "✦ Review with AI" button and the wizard's "✦ Build with AI" control do not render at all, but the plan toolbar's "✦ Assess" button and the command-palette entry always render — pressing them opens the assessment dialog showing "AI is turned off. An admin can enable it in Settings → Maintenance." at zero cost.

The three AI features

FeatureWhere it appearsWhat it producesOutput/time budget
Plan review"✦ Review with AI" button in the Apply dialogUp to 6 semantic findings, each with severity, one of three check types, the real issue keys involved, the finding and a suggestion1500 output tokens, 30 s timeout
Plan assessment"✦ Assess" button in the plan toolbar (also in the command palette as "Assess plan with AI")A health verdict (on-track / at-risk / off-track), a one-sentence headline, up to 6 top risks, a plain-language critical-path narrative and up to 6 recommended actions1400 output tokens, 35 s timeout
JQL assist"✦ Build with AI" under a JQL source in the plan creation wizardOne JQL query plus a one-sentence explanation and a confidence score500 output tokens, 20 s timeout; your prompt is truncated to 500 characters

Which model runs them

All three call the first-party Forge LLMs API (@forge/llm) with the model id claude-haiku-4-5-20251001, declared once in src/services/ai/model.js so a model change is a one-line edit. The app manifest declares an llm module with the claude model family and Atlassian resolves the hosted model — no external API key, no data leaving the Atlassian-hosted path. Every call runs at temperature 0 and forces a structured function call (report_findings, report_assessment, emit_jql) so results come back as objects rather than prose that has to be parsed; each has a prose-JSON fallback for when the model ignores the forced call. The model id is displayed as a pill in the review and assessment dialogs.

Only three checks, by design

The plan review is instructed to report ONLY semantic problems a deterministic rule engine cannot detect, and never to restate mechanical facts. The three checks are: semantic_precedence_conflict (a summary implies a real-world order that contradicts the dependency edges or the dates — documentation scheduled before the thing it documents exists); unrealistic_compression (a duration that collapsed in a way that reads like a data-entry error given the task and how its peers are sized); missing_intended_dependency (two summaries that strongly imply a blocks relationship the graph does not encode). An empty findings list is an explicitly valid, expected answer.

TipEvery AI result is advisory and read-only. Both dialogs say so in a footer — the review dialog: "✦ Uses AI — these are semantic hunches and can be wrong. Verify before acting; nothing here changes your plan."; the assessment dialog: "✦ Uses AI — these are heuristic judgements and can be wrong. Verify before acting; nothing here changes your plan." No AI path writes a date, a link or a rank. The JQL suggestion is handed to the wizard's normal query box, where the existing strict validator checks it live, so a hallucinated field is caught deterministically before you can use it.

Hallucinated issue keys are dropped, not shown

Before a finding reaches you, every cited issue key is checked against the keys actually in the plan and invented keys are removed; a finding left with no valid key (or no finding text) is discarded, and discarded findings that had real text are counted separately, so "the model said nothing" can be told apart from "the model said something it could not attribute". Severities outside high/medium/low become low, findings are sorted highest severity first and capped at 6, and the finding and suggestion texts are trimmed to 400 characters. The assessment is coerced the same way: health must be one of the three verdicts (anything else becomes at-risk), the headline is capped at 200 characters, the narrative at 1200, risks and actions at 6 each with 240-character texts and at most 12 issue keys per list; a risk with an unrecognised severity defaults to medium.

The cost guards, in the order they run

GuardBehaviour
Kill switchIf the admin toggle is off, the resolver returns { enabled: false } before anything else. Zero spend.
Result cacheKeyed by plan + account + a stable FNV-1a hash of the exact summary sent, with a 10-minute TTL. A repeat run on unchanged data costs 0 tokens and is badged "CACHED · 0 tokens" in the dialog. Failed, unparseable or truncated (incomplete) results are never cached.
Monthly capDefault 500 calls per month, checked against the cfg:ai:usage:<YYYY-MM> meter. When reached, the call is blocked BEFORE any spend and the dialog reports the monthly limit with used-of-cap.
Daily capDefault 100 calls per day, checked against cfg:ai:usage:day:<YYYY-MM-DD>. Same behaviour, scoped to the day.
MeterOnly round-trips that actually returned usage data are counted, so a failed call does not consume your allowance. The monthly meter records calls, input tokens, output tokens and a per-feature split; the daily meter records calls only.
CarefulThe caps are SHARED across all three features — reviews, assessments and JQL suggestions all draw on the same monthly and daily counters; the per-feature label only splits the reporting. A cap of 0 means unlimited, not blocked; to block the feature, use the toggle. Caps are counted in CALLS, not tokens or currency, so they bound volume rather than spend directly.

The Maintenance tab's AI controls

The toggle
A switch labelled Enabled/Disabled. Flipping it shows "AI plan review enabled" or "AI plan review disabled".
Usage line
"This month: N calls · X in / Y out tokens", plus "R of C left this month" coloured green, or amber when the remaining count hits zero.
Per-feature split
Coloured chips breaking this month's calls into Plan review (blue), Assessment (purple) and JQL assist (cyan), so you can see where the budget goes. Features with zero calls are hidden.
Monthly limit / Daily limit + Save limits
Two number boxes. Values are floored at 0 and truncated to whole numbers, both in the UI and again in setAiConfig. Saving toasts "AI spend limits saved". The hint reads "0 = unlimited. Reviews above the limit are blocked (no spend) until it resets."
✦ Run a test review
Disabled unless AI is enabled. Runs one real review against a synthetic two-issue plan (TEST-1 "Write API documentation for the billing service" scheduled before TEST-2 "Build the billing service API") to prove the round-trip works, reporting "Working — round-trip OK, N finding(s)" — with "(cached)" appended on a cache hit — or the specific failure.
LimitThe test review is a real model call and counts against your monthly and daily meters like any other. It is also cached for 10 minutes under the pseudo-plan id __aitest__, so pressing it twice in quick succession returns the cached answer.
LimitLarge plans are reviewed only in part. The summary sent to the model is capped at 120 issues, ordered so changed issues go first, with each summary trimmed to 90 characters and at most 8 predecessors per issue. When the cap bites, the review dialog appends "· partial (large plan)". Separately, if the model hits its output-token ceiling, the review dialog shows "⚠ The AI ran out of room — these results may be partial. Re-run for a complete review." and the assessment dialog appends "· partial (cut off)".
CarefulThe AI configuration resolvers (getAiConfig and setAiConfig) are NOT admin-gated, unlike every other admin setting — they live in ai-resolvers.js and never go through the adminOnly wrapper. Anyone with app access who calls setAiConfig directly can turn AI on or change the caps. If the kill switch matters to you commercially, monitor the usage line on the Maintenance tab rather than assuming only admins can move it.

57Maintenance: orphan cleanup and the destructive actions

What orphaned plan data is, how the scan proves a plan is really gone before deleting it, and the two actions that permanently destroy data.

CarefulEverything in this section deletes data permanently. There is no undo, no recycle bin and no export step. Forge storage has no snapshot you can roll back to. Read the whole section before pressing anything. Both destructive actions require Jira ADMINISTER — they are wrapped in the adminOnly gate — but the scan that precedes cleanup is not.

What an orphan is

Every plan owns a set of storage keys prefixed p:<planId>: — meta, idx (issue-to-shard index), deps, drafts, lock, sched, bl (baseline), pres (presence) and the numbered issue shards. The registry of live plans is a single key, plans:list. When a plan creation half-fails or a delete is interrupted, keys can survive whose plan ID is no longer in that registry — data nobody can reach through the UI that still consumes your storage quota.

Scan for orphans (read-only, safe)

  1. 1Press "Scan for orphans" on Settings → Maintenance.
  2. 2The app pages through every key beginning with p: (100 at a time) and extracts the plan ID from each key with the pattern p:<planId>:, then subtracts the IDs present in plans:list.
  3. 3Each remaining candidate is CONFIRMED individually with a strongly-consistent read of its metadata key, because the key enumeration is only eventually consistent and a plan created moments ago can legitimately be missing from it. A candidate whose meta still exists is not reported.
  4. 4A clean result shows "✓ KVS is clean — No plan IDs in storage are unaccounted for." and toasts "No orphaned plan data found". Otherwise the orphan IDs are listed, each marked "not in plans:list".
TipThe cleanup refuses to run when plans:list is empty but plan data still exists in storage — the refusal is armed only for the destructive path, so a plain scan still reports. The code's reasoning: the registry is one monolithic key written by read-modify-write from several call sites with no compare-and-swap available, so a single lost update makes every real plan look unregistered. That reading is the signature of a LOST REGISTRY, not of orphaned data, and deleting on it would destroy the whole site's plans. The action fails with a message telling you to recover the plans instead.

The two destructive actions

ActionWhere it isWhat it deletesConfirmation
Delete N orphansSettings → Maintenance, a red button that appears only after a scan finds orphansEvery storage key belonging to each confirmed orphaned plan ID — first the known keys via deleteAllPlanData, then a paged sweep of anything else still under p:<planId>:A danger dialog titled "Clean up KVS": "Delete every KVS key for N orphaned plans? This cannot be undone." with a Delete button
Purge StorageThe Plans list, but ONLY when the list failed to load — a red "Purge Storage" button under the error messageEVERY plan in plans:list, one by one through the same deleteAllPlanData path, then empties the registry. This is total data loss for the app: all plans, all indexed issues, all dependency graphsA danger dialog titled "Purge Storage": "Purge ALL plan data? This will clear orphan storage and cannot be undone." with a "Purge Everything" button
CarefulPurge Storage is an emergency recovery hatch for a site whose storage is so broken that the plan list will not render. It does not target orphans — it deletes every registered plan you have. Because it only appears in the plan list's error state, it is easy to press while assuming it is a refresh. It is not. It uses the same per-plan delete as a normal delete — deleteAllPlanData, which since the residue-sweep fix removes everything under each plan's p:{planId}: prefix (schedules, baselines, presence, per-user drafts included) — so a purge no longer leaves orphan keys behind for the next scan to find.

What a normal plan delete leaves behind

Deleting a plan removes its metadata, issue-to-shard index, dependency graph, drafts registry, write lock, schedule, baseline snapshot, presence roster, plans:scope entry and every recorded issue shard, in batches of 3 with a 200 ms pause between batches to stay under the storage throughput limit — then sweeps whatever still remains under the plan's p:<planId>: prefix (per-user drafts, shards beyond the recorded count), so a delete no longer manufactures orphans. The orphan scan exists for keys left behind by OLDER builds' deletes — which skipped :sched, :bl, :pres and :d:<accountId> — and by half-failed creations. Scanning and occasionally cleaning is normal housekeeping, not a sign of a bug.

Reading the cleanup result

Success toast
"Cleaned N plans (M keys)" — N is the number of orphan IDs processed, M the number of keys removed by the per-orphan prefix sweep (the keys deleted by deleteAllPlanData beforehand are not counted, and the counter increments even for a key whose individual delete failed).
Partial failure
The toast turns amber and appends "— N errors". The per-key error detail is written to the browser console; the cleanup does not stop on an individual failure.
After cleaning
The orphan list is cleared from the screen. Re-run the scan to confirm.
NoteOne deliberate design note in the cleanup code, because it affects your bill: an earlier version re-ran a full p:-prefix scan once per orphan. Forge storage queries return the full VALUE of every matching key with no keys-only mode, so that scan downloaded every issue shard of every plan — hundreds of megabytes on a large site — and threw it away, once per orphan. Forge bills storage by bytes read. The full scan now runs once per cleanup, and the only per-orphan query left is bounded to that orphan's own p:<planId>: prefix. Keep that in mind before scripting repeated scans — and note that previewOrphanedPlans, which runs the full scan, has no permission gate at all.

58What the app stores, and where

Every byte the app persists lives in Forge KVS (Atlassian-hosted app storage); this section lists every key, what a stored issue row contains, and which personal data appears in it.

LeanZero Management keeps no database of its own. All persistent state is written to Forge KVS through @forge/kvs, declared by the single storage:app scope in manifest.yml. Most of the key schema is defined in src/services/kvs-keys.js, which also records the two platform constraints it is designed around: keys must stay under 500 characters (hence the short p: prefixes) and each value must stay under 240 KiB. Note that the cfg: keys are NOT all in that file — cfg:engine and cfg:working-days are string literals in src/services/config-loader.js and src/resolvers/admin-resolvers.js, cfg:jqlFields in src/services/field-setup.js, and the cfg:ai* family in src/resolvers/ai-resolvers.js. Only plans:list, plans:scope, cfg:fields, cfg:holidays:{year} and the whole p: family come from kvs-keys.js.

Every KVS key the app writes

Key patternContentsHow many exist
plans:listArray of plan summaries: id, name, issueCount, updatedAt, statusOne per installation
plans:scopeReverse index for the issue-updated trigger: { planId: [projectKeys] }, each entry the project keys of that plan's indexed issues. Rewritten at index/refresh time, backfilled by the incremental updater, entry removed by deleteAllPlanData. A missing entry fails OPEN — the plan stays a candidateOne per installation
p:{planId}:metaPlan metadata (see the next block)One per plan
p:{planId}:idxIssue-to-shard index: { "ABC-123": 0, "ABC-124": 0, … }One per plan
p:{planId}:s:{n}One shard: an array of up to 100 issue rowsceil(issueCount / 100) per plan
p:{planId}:depsPer-link lag map, keyed "PRED|SUCC" → lag in working daysOne per plan
p:{planId}:d:{accountId}One user's draft: accountId, displayName, createdAt, updatedAt, changes, calculatedResults, issueKeys, and a stale/staleReason pair once invalidatedOne per user with unsaved edits
p:{planId}:draftsLightweight drafts registry: accountId → { displayName, issueKeys, timestamp, stale }One per plan
p:{planId}:lockWrite lock: accountId, displayName, timestamp, token, expiresAtOne per plan, only while an Apply is running
p:{planId}:schedPlan working days + holidays: { workingDays:[1,2,3,4,5], calendarName, holidays:[{date,name}] } (defaults to Mon–Fri, "Standard (Mon-Fri)", no holidays until a schedule is saved)One per plan that has been edited
p:{planId}:blUser-set baseline snapshot: takenAt, count, and per issue { startDate, dueDate, duration }One per plan
p:{planId}:presPresence roster: accountId → { accountId, displayName, avatarUrl, view, ts }One per plan
cfg:fieldsField-ID overrides. config-loader's defaults cover six ids (startDate, dueDate, duration, buffer, bufferPercentage, rank); the auto-detection writer stores five of them and never writes rank, so rank falls back to the default customfield_10019One per installation
cfg:engineEngine, dependency, buffer and indexing settings from the admin pageOne per installation
cfg:working-daysCalendars: activeCalendar, built-in + custom day setsOne per installation
cfg:holidays:{year}Global per-year holiday list. Still live, not merely legacy: admin-gated resolvers write it (saveHolidays/addHoliday/removeHoliday — though no screen in the app invokes them today) and loadBankHolidays reads the current + next year for the backend working-day context, which both the recalculation engine and the Plan Protection guard consume. Plans additionally carry their own holidays in p:{planId}:schedOne per year used
cfg:jqlFieldsCached list of the instance's JQL clause names, up to 150, treated as stale after 1 hourOne per installation
cfg:ai, cfg:ai:usage:{YYYY-MM}, cfg:ai:usage:day:{YYYY-MM-DD}AI kill-switch + caps, and the review/token meters (monthly meter holds reviews, inputTokens, outputTokens and a per-feature split; the daily meter holds reviews only)One config key, one meter per month and per day
cfg:ai:cache:{planId}:{accountId}:{hash}, cfg:ai:assess:{planId}:{accountId}:{hash}, cfg:ai:jql:{accountId}:{hash}Cached AI results. The review and assessment caches are keyed by plan, account and a content hash; the JQL-builder cache is keyed by account and a hash of the prompt plus the optional project context ({ prompt, projectKey }) — there is no planId in itOne per distinct request
CarefulGOTCHA — none of the caches use a KVS TTL. cfg:ai:* results and cfg:jqlFields are written with a plain storage.set and an at timestamp; the 10-minute and 1-hour windows are compared in code on read. A stale entry is overwritten on the next miss but is never deleted, so a cache key for a plan or account that is never touched again stays in KVS indefinitely.

What p:{planId}:meta holds

  • Identity and audit: id, name, createdBy (accountId), createdByName, createdAt, updatedAt, lastIndexedAt
  • Sources: an array of { id, type, label, query, boardId, projectKey } — one entry per JQL, board or project source
  • Size: issueCount, shardCount, and contentHash (the fingerprint the hourly refresh compares against, stamped by both the full index and the refresh)
  • Behaviour flags: calendarKey (default standard), holidayYears (defaults to the current and next year), milestones, protectionEnabled (default true), includeParents (default true for new plans)
  • State machine: status (created → queued → indexing → indexed, or writing / error), statusMessage, version (incremented on every change), lastWriterName (set at the start of an Apply)
  • Access control: members[] (accountId, displayName, role, addedAt) and defaultAccess (none by default). A legacy editors[] array is still honoured on read for backward compatibility

What one indexed issue row contains (src/services/indexing/issue-transformer.js)

FieldValueNote
key, summary, type, hierarchyLevelIssue key, truncated summary, issue type name, Jira hierarchy levelSummary is cut to indexing.maxSummaryLength, default 80 characters. When Jira omits hierarchyLevel the fallback is -1 for sub-tasks, 0 otherwise
status, statusCategoryStatus name and status-category keystatusCategory is new / indeterminate / done, or undefined when Jira sends none
parentKey, orphanParentKeyParent inside the plan; parent that exists in Jira but is outside the planorphanParentKey is why the UI can explain a top-level row that really has a parent
assigneeName, reporterDisplay names onlyNo email address, no accountId, no avatar is stored on the issue row
priority, labels, resolution, storyPointsPriority name, label array, resolution name, and customfield_10016 — the story-points field id is hardcoded, not configurable
created, updatedDate only — the timestamp is sliced to YYYY-MM-DD
startDate, dueDate, duration, buffer, rankThe four scheduling fields plus the Jira LexoRank stringField IDs come from cfg:fields, not hardcoded. duration is parsed to a number, buffer normalised to "Yes"/"No"
predecessors, successors, childrenArrays of issue keys, filtered to keys that are in this planOnly links whose type name matches dependencies.linkTypeName (default Blocks) count. Links outside the plan are dropped, not stored
_originalSnapshot of startDate, dueDate, duration, buffer, predecessors, successors as Jira had them at index timeThis is the baseline the Apply diff and conflict detection compare against
NoteNothing else from the issue is stored. Descriptions, comments, attachments, worklogs, custom fields other than the configured duration/buffer/rank/story-points, and the full changelog are never fetched (see the field list in getIndexFields) and never persisted. The personal data that does land in KVS is: assignee and reporter display names on issue rows; accountId + display name for plan owners, members, draft owners and the presence roster; and the 24x24 avatar URL in the presence roster.

59How sharding works

Issues are stored 100 to a shard with a separate key-to-shard index, so a single issue can be read without loading the plan and no value approaches the 240 KiB KVS ceiling.

A Forge KVS value cannot exceed 240 KiB, so a plan's issues cannot live in one key. saveAllIssues in src/services/kvs-store.js splits them into fixed-size shards and writes a separate index that maps every issue key to its shard number.

The sharding algorithm, exactly

  1. 1Sort every issue key alphabetically. This makes shard assignment deterministic — the same set of issues always produces the same layout, whatever order Jira returned them in.
  2. 2Assign shard number floor(i / SHARD_SIZE) where i is the position in that sorted list and SHARD_SIZE = 100 (src/services/kvs-keys.js). So shard 0 holds the first 100 keys, shard 1 the next 100, and so on.
  3. 3Write each shard to p:{planId}:s:{n} in parallel batches of 5.
  4. 4Write the index { issueKey → shardNumber } to p:{planId}:idx.
  5. 5If the plan SHRANK, delete shards from the new count up to the previously recorded meta.shardCount. Without this step a shrinking re-index leaves high-numbered shards orphaned in KVS — they would even survive plan deletion, because deletion also iterates up to meta.shardCount.
  6. 6Return the new shard count; the caller (runIndexing or refreshOnePlan) stamps it onto meta.shardCount. Everything that reads the whole plan trusts that number.

What each read costs

OperationKVS operationsHow
getIssue(planId, key)2 readsOne index read, then only the one shard that contains the key
getIssuesByKeys(planId, keys)1 read + one read per distinct shard touchedKeys are grouped by shard first so a shard is never fetched twice. Unlike the other bulk paths this one is NOT batched — every grouped shard read is issued at once under a single Promise.all
getAllIssues(planId)1 meta read + shardCount shard readsShards are loaded in parallel batches of 5 to stay clear of KVS throughput limits
updateIssuesInShards1 index read, then 1 read + 1 write per touched shardRead-modify-write per shard, 5 shards at a time; the batching uses thunks, not eager promises, so it actually throttles
deleteAllPlanData5 fixed-key deletes + shardCount deletesDeleted in batches of 3 with a 200 ms pause between batches

Numbers worth knowing

Issues per shard
100 — the hardcoded SHARD_SIZE constant. See the warning below about the admin setting of the same name.
Shard size on disk
About 30 KB for a full 100-issue shard according to the estimate documented in src/services/kvs-store.js and the admin UI, against a 240 KiB per-value ceiling — roughly an eight-fold headroom. This is a documented estimate, not a measured figure in the codebase.
Shards per plan
ceil(issueCount / 100). A 5,000-issue plan is 50 shards plus one index key.
Concurrency
5 shard operations in flight at once — a BATCH_SIZE = 5 local constant repeated in getAllIssues, saveAllIssues and updateIssuesInShards.
CarefulGOTCHA — the admin setting "Issues per Storage Shard" (indexing.issuesPerShard, default 100) does NOTHING. It is defined in config-loader's defaults and rendered as an editable number field on the admin Engine Config screen, but no code reads it: sharding uses the hardcoded SHARD_SIZE in kvs-keys.js. The same is true of indexing.batchSize (default 5), which is not read anywhere and is not even surfaced in the admin UI. Of the three indexing.* settings only maxSummaryLength is genuinely live — the transformer reads it.
CarefulGOTCHA — shard order is not display order. Shards are built in alphabetical key order for determinism, which loses Jira's rank ordering. The getAllIssues resolver therefore re-sorts by the stored LexoRank rank string (falling back to the issue key when rank is missing, so a plan with partial rank data sorts on a mix of the two) before returning rows to the timeline. If you read shards directly, you get alphabetical order, not the order the Gantt shows.
NotePer-link lags live in their own key (p:{planId}:deps), NOT on the issue rows. That is deliberate: lag cannot be represented in Jira, so a re-index — which rebuilds every shard from Jira — would wipe it. Lags are denormalized onto issue.predecessorLags at read time by applyLagsToIssues, which reconciles each issue to exactly match the map and deletes stale values, so a cleared lag cannot survive on a shard.

60Where the app runs: Atlassian only, no data egress

The manifest declares no external hosts, the app depends only on @forge packages, and the AI feature uses Atlassian's own hosted LLM — so plan data never leaves Atlassian infrastructure.

A Forge app can only make outbound calls to hosts it declares in the manifest's permissions.external block. manifest.yml has no such block — the permissions section contains exactly eleven OAuth scopes and one content-security relaxation (content.styles: unsafe-inline, needed by the bundled UI). No external domain is declared, so the platform will not let the app reach one. There is no analytics endpoint, no telemetry, no license server, no LeanZero-hosted backend.

How that is verifiable from the source

  • package.json runtime dependencies are only @forge/api, @forge/events, @forge/kvs, @forge/llm, @forge/realtime, @forge/resolver — no HTTP client, no SDK for a third-party service. The only devDependencies are playwright and yaml, which never ship.
  • Every Jira call in src/ goes through api.asApp().requestJira(...) or api.asUser().requestJira(...), which can only address the installing Jira site.
  • There is no api.fetch call to an external URL anywhere in src/.
  • The UI is a static bundle served from the app resource ppm-ui (static/ppm-ui/build); it talks only to the app's own resolvers over the Forge bridge.

The three places data moves at all

PathWhat movesWhere it goes
Jira RESTIssue fields listed in getIndexFields, plus project/board/filter metadataBetween the app and the installing Jira Cloud site only
Forge KVSEverything in the storage table aboveAtlassian-hosted app storage for this installation
Forge LLM (@forge/llm)A capped plan summary, when an admin has switched the AI features onAtlassian's hosted model service — the manifest's llm module declares the claude family and src/services/ai/model.js pins claude-haiku-4-5-20251001

What the AI feature actually sends

The AI review, plan assessment and JQL builder are OFF by default (cfg:ai.enabled defaults to false) and every call goes through the shared guardedAi wrapper — enabled gate, result cache, monthly and daily cap — before any spend. The payload is the output of buildPlanSummary: at most 120 issues (changed issues sorted first), each reduced to key, summary truncated to 90 characters, type, start, due, duration, an optional buffer flag, parent key, up to 8 predecessor keys, and the before/after values of a changed row. No assignee, no reporter, no labels, no description, no comments. Responses are capped at 1,500 output tokens (review), 1,400 (assessment) and 500 (JQL builder), with 30 s / 35 s / 20 s timeouts and temperature 0 on all three.

CarefulBe precise about where that cap lives: buildPlanSummary is a FRONTEND module (static/ppm-ui/src/utils/ai-summary.js). PlanView builds the summary and passes it to the reviewPlanWithAI / assessPlan resolvers, which forward it to the model unchanged — there is no server-side re-cap and no server-side field filtering. The 120-issue / 90-character bound is enforced by the shipped client, not by the resolver.
CarefulGOTCHA — realtime events use a global channel. Background contexts (the queue consumer, the issue-updated trigger, the scheduled refresh) cannot use the scoped publish API, so emitPlanEventGlobal uses publishGlobal on the channel plan:{planId}. Globally published events are not permission-scoped, as the module's own security note records. The mitigation is that these payloads are metadata only — { v, type, planId, ts, ... } plus fields like version, issueKeys and external, well under 1 KB — and clients re-fetch real state through the normal resolvers. One caveat on the note's own reasoning: it argues the exposure is negligible partly because "planIds are unguessable UUIDs", but plan ids are actually plan-{base36 timestamp}-{6 random base36 chars} (generatePlanId in plan-resolvers.js), which is a weaker identifier than a UUID. The payload being non-confidential is what carries the argument, not the id.
LimitThere is one web trigger in the manifest, harness-test-state. It exists for the automated end-to-end test harness and is gated on the HARNESS_SECRET environment variable, which is set only in the development environment. If the variable is absent — which it is in production — the handler returns 404 before doing anything, and it also returns 404 when the caller's Bearer token does not match. It is not read-only beyond createFixture/deleteFixture/settle, though — the file's own header comment still claims that three-action list, and its actions have outgrown it: setLag writes a per-link lag into p:{planId}:deps and leaves it, lagRoundtrip writes a lag and then writes 0 back, applyEdit writes an arbitrary field value into a stored issue shard, refreshPlan runs the real per-plan refresh with force (rewriting shards and meta), incrementalUpdate and incrementalUpdateTimed run the real issue-updated path (the timed variant also reports the in-invocation duration, the billed axis for the plans:scope cost work), and scheduledConsume runs the real queue consumer — refresh plus draft cleanup. deleteFixture tears down through deleteAllPlanData, so it no longer orphans the plan's idx/deps keys as the old inline version did. None of this has production surface — without the secret every action 404s.

61Every OAuth scope, and why it is there

Eleven scopes, each mapped to the specific REST endpoints the app calls — including plain answers to the three questions a security reviewer always asks.

Declared scopes (manifest.yml)

ScopeWhy the app needs itEndpoints it covers
read:jira-workRead the issues a plan is built from, ask Jira the cheap "has anything changed?" question, and back the JQL editorPOST /rest/api/3/search/jql, POST /rest/api/3/issue/bulkfetch, GET /rest/api/3/issue/{key}, POST /rest/api/3/search/approximate-count, GET /rest/api/3/jql/autocompletedata (+ /suggestions), POST /rest/api/3/jql/parse?validation=strict, GET /rest/api/3/filter/search
read:issue-details:jiraRead the specific issue fields the index stores — summary, type, status, parent, assignee, the four scheduling fields, rank, links and sub-tasksField-level reads on the endpoints above; also GET /rest/api/3/issue/{key}/editmeta
read:project:jiraPopulate the source picker and show a project's real name, type, style, lead, non-subtask issue types and approximate size before you commit to it; also resolve a project's id during field setupGET /rest/api/3/project/{key}, GET /rest/api/3/project/search
read:jira-userName the person holding the write lock, attribute drafts and presence to a real display name, search for users when adding plan members, and check whether the caller is a Jira administratorGET /rest/api/3/myself, GET /rest/api/3/user/search, GET /rest/api/3/mypermissions?permissions=ADMINISTER
write:jira-workApply the plan back to Jira: write the four scheduling fields, create and delete the dependency links you draw on the timeline, and post the explanatory comment when Plan Protection reverts an out-of-app date changePUT /rest/api/3/issue/{key}, POST /rest/api/3/issueLink, DELETE /rest/api/3/issueLink/{id}, POST /rest/api/3/issue/{key}/comment
write:issue:jira-softwarePersist a drag-reorder of rows as a real Jira rank change instead of an app-only orderingPUT /rest/agile/1.0/issue/rank
read:board-scope:jira-softwareIndex a board source, and list boards in the source pickerGET /rest/agile/1.0/board/{id}/issue, GET /rest/agile/1.0/board, GET /rest/agile/1.0/board/{id}
read:board-scope.admin:jira-softwareShow a board's TRUE scope before you add it as a source — a board's contents are defined by a saved filter, so the app reads the board configuration and that filter's JQL and shows both, plus the board's column namesGET /rest/agile/1.0/board/{id}/configuration, GET /rest/api/3/filter/{id}
manage:jira-configurationOne-time field setup on first index: find or create the two custom fields the app needs, and put them on the edit screen so Apply can actually write themGET /rest/api/3/field, POST /rest/api/3/field, GET /rest/api/3/screens, GET /rest/api/3/screens/{id}/tabs, POST /rest/api/3/screens/{id}/tabs/{tabId}/fields
manage:jira-projectResolve WHICH edit screen a given project uses, so the fields are added to the right screen rather than only to the Default ScreenGET /rest/api/3/issuetypescreenscheme/project, GET /rest/api/3/issuetypescreenscheme/mapping, GET /rest/api/3/screenscheme
storage:appForge KVS — the app's only persistence@forge/kvs

Reviewer question 1: what does manage:jira-configuration actually do?

It runs on the first index of a plan, and only if the fields are not already configured (areFieldsConfigured checks that cfg:fields.startDate and cfg:fields.duration are both set). The app fetches the full field list, looks for a field named "Start date"/"Start Date", "Duration"/"PPM Duration" and "Buffer"/"PPM Buffer", preferring an existing custom field over a system one. If Duration or Buffer are missing it creates them — a float field named "PPM Duration" and a select field named "PPM Buffer", both with a description that names LeanZero Management as the creator. It then adds those fields (plus Start date) to the first tab of the edit screen resolved for each project in the plan's sources, AND — always, as a deliberate fallback — to the instance's "Default Screen". A field already on a screen is detected from the error body and logged as a no-op. That is the entire use. The app never edits a workflow, a permission scheme, a notification scheme, an issue-type scheme or a security level, and it never deletes a field. An admin can also re-run the same routine on demand from the admin page (detectFields).

Reviewer question 2: what does manage:jira-project actually do?

Nothing is created, modified or deleted with it. It is used purely to READ the chain project → issue-type screen scheme → screen scheme → edit screen id (screens.edit, falling back to screens.default), for each project that appears in a plan's sources. Without it the app can only fall back to the instance's "Default Screen", which is wrong for most company-managed projects and produces the classic failure where a date write returns HTTP 200 but the value is not stored. There is no project create, project delete, or project-settings write anywhere in the codebase.

Reviewer question 3: why overrideScreenSecurity=true on issue updates?

Every Apply write goes through updateIssue in src/services/jira-client.js, which calls PUT /rest/api/3/issue/{key}?notifyUsers=false&overrideScreenSecurity=true. Plan Protection's revert uses the same URL directly. The reason is the same failure mode described above: Jira rejects the ENTIRE PUT with a 400 ("Field '…' cannot be set. It is not on the appropriate screen, or unknown") if any field in the payload is missing from that issue's edit screen — which silently kills the date write bundled alongside it. notifyUsers=false prevents an email storm when an Apply touches hundreds of issues.

CarefulBe honest about what that flag means: with overrideScreenSecurity the app can set a field that a human could not set through the Jira UI on that screen. Three things bound it. (1) The Apply payload is built by buildFieldsPayload and can only ever contain the four configured fields — start date, due date, duration, buffer. (2) Before writing a chunk, the app calls GET /issue/{key}/editmeta for the FIRST issue in that chunk and drops any field that issue cannot accept, so a change that touches only an unsupported field becomes a no-op rather than a failed write; when editmeta is unreadable it falls back to attempting all fields. (3) After writing, it re-fetches every written issue and compares against the intended values; if anything did not persist, the Apply is reported as a retryable partial failure, KVS is NOT re-indexed over the user's intent, the plan version is not bumped, and the draft is kept so it can be retried.
CarefulOne nuance worth knowing: Plan Protection's revert path in src/services/plan-protection.js resolves the start-date field id from the app's own config (config.fields.startDate, loaded lazily only once a protected plan actually contains the issue) and falls back to customfield_10015 only when nothing is configured — so an instance whose start-date field was auto-detected to a different custom field gets its revert written to the right field. The due date always reverts to the system duedate. The changelog PRE-FILTER, though, matches by field name (or the default id), so a custom start field whose display name contains neither "start date" nor "due date" AND whose id differs from the default is never recognised as a date change in the first place.
LimitIndexing runs as the APP, not as the viewing user. Every source fetch (searchIssuesPaginated, getBoardIssues, bulkFetchIssues) uses api.asApp(), as do the hierarchy walks and the change probe. That means a plan shows what the app can see, which may be more than a particular viewer could see in Jira. The source-picker resolvers in jira-meta-resolvers.js are the exception — requestAsUserWithFallback tries api.asUser() first and falls back to asApp() on 401/403 — so counts and suggestions while building a source reflect the user. Member search (/user/search) runs as the app. Treat a plan as visible to everyone you grant plan access to.

62How and when a plan refreshes

An hourly scheduled trigger fans out one job per plan, an issue-updated trigger syncs single issues in near real time, and two gates skip all the expensive work when nothing has actually changed.

There are two automatic refresh paths, plus the manual re-index. The hourly one is a safety net that catches anything the event stream missed; the event-driven one keeps a single edited issue current within seconds.

The hourly path, step by step

  1. 1The ppm-hourly-refresh scheduled trigger fires once an hour (interval: hour) and calls onScheduledRefresh.
  2. 2It reads plans:list and builds one queue event per plan: { planId, reason: 'scheduled' }. If the list is empty it returns immediately.
  3. 3Events are pushed to the ppm-index-queue in batches of 50 — the maximum @forge/events accepts per push request. Batching exists because one push per plan runs into the documented 500-events-per-minute-per-installation limit; when that throttled, Promise.all rejected on the first failure without cancelling the pushes that had already succeeded, and the old catch then re-refreshed every plan inline, doubling the work.
  4. 4Each event is handled by the indexConsumer function, which has a 900-second budget, so one slow plan cannot starve the others.
  5. 5The consumer looks the plan up in plans:list, calls refreshOnePlan, then opportunistically deletes drafts abandoned more than 24 hours ago (or already flagged stale).
  6. 6If the enqueue fails entirely (async events unavailable), the trigger falls back to refreshing plans inline and serially — but ONLY when there are 25 plans or fewer. Above that it logs and skips the tick, because an inline pass cannot finish inside the trigger's budget anyway and would duplicate work already queued.

The per-plan guards in refreshOnePlan, in the order they run

GuardConditionResult
No metadatap:{planId}:meta missingno-meta — nothing to refresh
Stale thresholdlastIndexedAt is less than 55 minutes agorecent — skipped. Deliberately under the hourly interval: it exists to stop a just-re-indexed plan being redone by the next tick, not to space out the schedule
Busystatus is writing or indexingbusy — never refresh under an in-flight Apply or index
GATE 1 — change probeJira reports zero issues updated since lastIndexedAtunchanged — skipped after one cheap count
Empty sourceAll sources returned zero issuesempty — the previously indexed data is kept
GATE 2 — content hashThe freshly built data hashes identically to meta.contentHashunchanged — returns having written NOTHING at all
NoteAll four force-able guards (recent, busy, gate 1, gate 2) are skipped when refreshOnePlan is called with { force: true }. Nothing in the production paths passes it — only the dev-only test harness does.

Gate 1: the change probe (src/services/indexing/change-probe.js)

Before any fetching, the app asks Jira one question with no issue fields and no payload: POST /rest/api/3/search/approximate-count with the JQL (<source scopes joined by OR>) AND updated > "yyyy-MM-dd HH:mm". The timestamp is lastIndexedAt in UTC, rounded DOWN to the minute, because JQL only has minute granularity and rounding up could step over an edit made in the same minute the index ran. A count greater than zero means "assume changed".

NoteThe probe FAILS OPEN, by design and by comment. It returns "assume changed" for a plan that has never been indexed, an unreadable timestamp, no sources, a failed or unavailable request, or a non-numeric response. It also cannot express a BOARD source as JQL — a board's scope lives in a saved filter, and resolving it would cost the very lookups the probe exists to avoid — and it refuses a project source whose key is not [A-Za-z0-9_]+ rather than interpolating it. Any one unprobeable source makes the whole answer unknowable and the refresh runs. A missed refresh is a correctness bug; a redundant one is only cost. Never invert that.

Gate 2: the content hash (src/services/indexing/content-hash.mjs)

The probe cannot see an issue LEAVING a plan (deleted, or edited so the source no longer matches), and it is blind to board sources. So after fetching and transforming, the app fingerprints what it just built and compares it to the stored one. The hash is "{rowCount}:{fnv1a(...)}" over, per issue, the key plus fifteen fields — summary, type, hierarchyLevel, status, statusCategory, parentKey, assigneeName, priority, resolution, storyPoints, startDate, dueDate, duration, buffer, rank — plus the predecessor, successor and children arrays SORTED (so a reordered issuelinks response is not a change). The row strings are then sorted, because Jira's key order is not stable.

Tipupdated is deliberately EXCLUDED from the hash. Jira bumps it for things a plan does not care about — a comment, a watcher change — and including it would defeat the gate entirely: every commented-on issue would force a full rewrite of every shard. On a hash match the code writes nothing at all, not even a "last checked" timestamp, because a per-plan meta write every hour is exactly the cost being removed, and lastIndexedAt staying put is correct — it is the watermark the probe compares against.

The incremental path (issue-updated trigger)

  1. 1The ppm-issue-guard trigger subscribes to avi:jira:updated:issue and calls onIssueUpdated for every issue update on the site.
  2. 2The plan list is narrowed ONCE for both steps that follow: the trigger reads the plans list and the plans:scope reverse index — one KVS key, { planId: [projectKeys] }, rewritten whenever a plan is indexed or refreshed and removed with the plan — and keeps only plans whose scope covers the updated issue's project key. When no plan covers the project — the dominant case on a large site — the handler exits after those two small reads, before any config load or Jira fetch. The filter is FAIL-OPEN: no scope map, a plan with no entry, or a malformed entry keeps that plan a candidate, and the incremental updater backfills the missing entry from the index it read anyway.
  3. 3Step 1 is Plan Protection, run over the candidate plans: validateAndProtect returns immediately unless the changelog contains a start- or due-date change. Otherwise, for each candidate plan that contains the issue AND has protectionEnabled, it computes the required successor start as the maximum over predecessors of the lag-adjusted next working day after the predecessor's due date. If the new start is earlier, the change is reverted with a PUT and an explanatory comment is posted naming the predecessors, the latest predecessor due date, the earliest allowed start and the requested start.
  4. 4If a revert happened, the handler stops — it does not then sync the value it just undid.
  5. 5Step 2 is incrementalUpdateIssue, handed the same candidate list: it fetches the issue from Jira ONCE, then, for each candidate plan whose index contains that key, transforms it against THAT plan's own key set (dependencies and parents are filtered by plan membership, so one shared transform would corrupt the other plans), writes it back into its shard, prunes lag entries for edges into it that no longer exist, bumps that plan's version and updatedAt, and emits a realtime plan:version event.
  6. 6Realtime events from both background paths carry external: true and no accountId, so an open timeline treats them as an outside change — it notifies, and never silently stomps the local edits you have in progress.
LimitCOST AND SCALE NOTE — this trigger fires for EVERY issue update in the whole Jira site, not just issues in a plan. It used to run listPlans() and then read every plan's index key on every event; the plans:scope reverse index cut that to two small KVS reads plus per-plan index reads only for the candidates whose scope covers the issue's project — and an immediate exit when none does, the dominant case on a large site. Step 1 still adds a per-candidate meta read whenever dates changed. Even narrowed, this is the single highest-volume code path in the app. The hourly refresh is the other one, which is precisely why it carries two gates.

63What happens on a re-index

A full re-index is queued, not synchronous; it rebuilds every issue row from Jira, discovers the hierarchy in both directions, and has explicit safeguards against a source that suddenly matches nothing.

A manual re-index (and a first index) goes through the indexPlan resolver, which does almost nothing itself: it flips the plan's status to queued so a poller immediately sees a job in flight, pushes { planId } onto the ppm-index-queue, and returns. The heavy work runs in the queue consumer with a 900-second budget. Only if the enqueue fails does the resolver run the pipeline inline, where it is capped at the roughly 25-second synchronous resolver limit and large plans can time out.

The pipeline (src/services/indexing/run-index.js)

  1. 1Set status indexing, clear any previous status message, stamp updatedAt.
  2. 2If fields are not configured yet, run the one-time field auto-detection and screen setup for every project key mentioned in the plan's sources (project sources directly, plus a project = X match extracted from JQL sources by regex).
  3. 3Load the admin config and build the CONFIGURED Jira field list — not the hardcoded defaults — so an instance whose Duration/Buffer live on non-default custom fields actually gets those values.
  4. 4Fetch every source, deduplicating by issue key. JQL and project sources page through POST /rest/api/3/search/jql at 100 issues per page using nextPageToken; project sources are just JQL (project = "X" … ORDER BY rank ASC). Board sources page the Agile board endpoint, advancing by the actual returned count because that endpoint caps below the requested page size, and stopping on an empty page.
  5. 5Walk DOWN: discoverDescendants runs a breadth-first parent IN (…) search, 80 keys per clause, up to 25 rounds, terminating when a round finds nothing new — this is what stops an epic-only source rendering parents with no rows under them. It then sweeps every fetched issue's subtasks[] and bulk-fetches anything the walk missed.
  6. 6Walk UP, only if the plan has includeParents (default ON for new plans): bulk-fetch parents that the sources did not match, then their parents, and so on, up to the same 25-round ceiling. This runs strictly AFTER the descendant walk and its results are never fed back into it — pulling in an epic and then walking down from it would drag in every other story under that epic.
  7. 7Transform to the lean issue model, shard, and save. Prune lag entries whose edge no longer exists in the rebuilt graph.
  8. 8Stamp metadata: status indexed, issueCount, shardCount, lastIndexedAt, updatedAt, contentHash, version + 1; update the plan's row in plans:list; emit an index:done realtime event so an open view stops polling and loads the fresh data.
TipThe content hash is stamped here too, not only by the hourly refresh. Otherwise the hourly gate would have to pay for one full re-index before it could ever engage.

What survives a re-index and what is rebuilt

DataFateWhy
Every issue row, including _originalRebuilt from JiraThe whole point of a re-index
Per-link lags (p:{planId}:deps)Survives, then prunedLag cannot be represented in Jira; orphaned edges are dropped so a recreated link does not resurrect an old lag
Baseline snapshot (p:{planId}:bl)SurvivesSeparate key; display-only variance data, never read by the cascade or the write path
Plan schedule and holidays (p:{planId}:sched)SurvivesSeparate key
Presence roster (p:{planId}:pres)SurvivesSeparate key, TTL-pruned on its own schedule
Members, defaultAccess, milestones, protection and includeParents flagsSurviveThey live on meta, which is updated field-by-field, not replaced
DraftsSurviveDrafts are only removed after a successful Apply, when flagged stale, or by the 24-hour cleanup
Shards above the new shard countDeletedExplicit shrink cleanup in saveAllIssues — otherwise they are orphaned forever
CarefulZERO-MATCH SAFEGUARD. If a re-index matches no issues but the plan previously had some, the app keeps the previous data, leaves status at indexed, bumps the version, and sets the message "The last re-index matched 0 issues — the previous data was kept. Check the plan's sources." It also does NOT advance lastIndexedAt. This exists because the old behaviour set shardCount to 0 and reported success, which instantly rendered "No issues indexed" — a whole schedule apparently gone — from a transient permission blip or a JQL that stopped matching. The shards were not even deleted, just orphaned and unreachable. A plan that genuinely never had issues still reports a normal empty state.

Failure and stuck-job handling

  • Any error in the pipeline records status: 'error' plus the message on the plan and emits an index:error event. The consumer deliberately does NOT re-throw — re-throwing would make the async platform retry a permanent failure (a bad JQL) for the entire retention window.
  • A source that throws during a FULL index fails the whole index (fetchFromSource is unguarded); during the HOURLY refresh a failing source is logged and skipped instead, so one broken source does not stop the others.
  • getIndexingProgress self-heals a dead job: a plan sitting in queued or indexing whose updatedAt is more than 15 minutes old is REPORTED as error with "Indexing timed out — please retry." — note it only changes what the resolver returns, it does not write the error status back to KVS. 15 minutes matches the consumer's own 900-second hard cap.
  • Hierarchy discovery is best-effort: a failed parent IN batch is logged and the walk continues, and a failed ancestor fetch breaks the upward walk and degrades the plan rather than failing the index.

64Limits — the real numbers

Every ceiling the code enforces or the platform imposes, with the value as it appears in the source — and a clear note where a documented "setting" is not actually wired up.

Platform and storage

LimitValueWhere it comes from
KVS value size240 KiB per keyPlatform; documented in src/services/kvs-keys.js and the reason sharding exists
KVS key length500 charactersPlatform; the reason keys use the short p:{id}:s:{n} form
Issues per shard100SHARD_SIZE, src/services/kvs-keys.js — a hardcoded constant, not read from config
Typical shard size~30 KB for a full shardDocumented estimate in src/services/kvs-store.js and the admin UI copy; not measured in code
Parallel KVS operations5 at a timeA local BATCH_SIZE = 5 repeated in getAllIssues, saveAllIssues and updateIssuesInShards. getIssuesByKeys is not batched at all
Plan deletion batching3 keys per batch, 200 ms pause betweendeleteAllPlanData
Baseline snapshot cap2,000 issuessetBaseline, to stay well under the value-size limit
Indexed summary length80 charactersindexing.maxSummaryLength — genuinely admin-configurable and read by the transformer

Function budgets and event ceilings

LimitValueNote
Synchronous resolver budget~25 secondsWhy indexing is queued rather than run in the resolver
Async consumer budget900 secondstimeoutSeconds: 900 on the indexConsumer function in manifest.yml — the platform maximum; async consumers default to 55 s
Events per push request50@forge/events limit; QUEUE_PUSH_BATCH in the scheduled trigger
Events per minute per installation500Documented platform limit; the reason the fan-out batches instead of pushing one event per plan
Inline fallback ceiling25 plansINLINE_FALLBACK_MAX_PLANS — above this the trigger skips the tick rather than start work it cannot finish
Scheduled trigger intervalhourlyinterval: hour in manifest.yml
Stale-refresh threshold55 minutesSTALE_THRESHOLD — deliberately under the hourly interval
Stuck-job threshold15 minutesSTUCK_MS in the indexing resolvers

Jira API limits and how the app stays inside them

LimitValueApp behaviour
Burst rateGET 100/s, POST 100/s, PUT 50/s, DELETE 50/sAtlassian's documented limits, recorded in the jsdoc of src/services/jira-client.js
Per-issue writes20 writes per 2 s, 100 per 30 sSame source — documented, not enforced in code
Global quota65,000 points per hourSame source
Retry policy4 attempts, 2 s base delay, 30 s maximum, jitter factor 0.7–1.3, honours Retry-After on 429requestWithRetry; 4xx other than 429 is not retried
Search page size100 issuesMAX_RESULTS_PER_PAGE
Bulk fetch chunk100 keys per requestbulkFetchIssues
Rank request50 issue keys maximumDocumented in the rankIssues jsdoc; not enforced in code, and the only caller (the rankIssue resolver) passes a single key
Hierarchy BFS80 keys per parent IN clause, 25 rounds maximumPARENT_BATCH / MAX_ROUNDS; 25 is far above any real Jira hierarchy and only guards against a cycle. discoverAncestors uses the same 25-round ceiling

The Apply (write-back) path

LimitValueWhy
Issues per write chunk10WRITE_CHUNK_SIZE — the frontend calls writeChunk repeatedly with an advancing offset
Delay between issues250 msWRITE_DELAY_MS — applied between issues inside a chunk, not after the last one
Write lock TTL5 minutes, refreshed after every chunkLOCK_TTL_MS; a failed refresh aborts the write rather than risking a second writer
Fields writable4 only: start date, due date, duration, bufferbuildFieldsPayload; anything else is impossible by construction
Progress eventsOne per chunk, never per issueKeeps the realtime channel well clear of any flood limit
CarefulBATCH_PAUSE_MS = 2000 is declared at the top of src/resolvers/write-resolvers.js and is NEVER USED — grep finds exactly one occurrence, the declaration. There is no server-side pause between write chunks; chunk pacing is entirely whatever cadence the frontend calls writeChunk at. Do not quote it as a rate-limit control.

Engine settings on the admin page (src/services/config-loader.js)

SettingDefaultIs it actually read?
engine.maxCascadeDepth10Yes — by chain-calculator.js, i.e. only the BACKEND calculation engine
engine.maxParentIterations5Yes — by calculation/engine.js, backend only
engine.maxTraversalDepth15No. Defined and shown in the admin UI; no code reads it
engine.maxIssuesPerTraversal150No. Defined and shown in the admin UI; no code reads it
dependencies.linkTypeNameBlocksYes — the transformer uses it to decide which issue links are predecessor/successor edges
buffer.impactPrefix[BUFFER IMPACT]Yes — recognised by the roll-up; it does not create anything
indexing.maxSummaryLength80Yes — the transformer truncates to it
indexing.issuesPerShard100No. Editable in the admin UI but sharding uses the hardcoded SHARD_SIZE
indexing.batchSize5No. Not read anywhere and not shown in the admin UI
CarefulImportant context for the two engine settings that ARE read: the frontend cascade engine (static/ppm-ui/src/hooks/cascade-core.js) is the authoritative one. The user's edit is cascaded in the browser, those dates are persisted to KVS, and Apply writes them straight to Jira — the backend recalculateFullPlan/recalculateFromIssue engine is exposed as resolvers but is not on that path. So maxCascadeDepth and maxParentIterations do not bound what actually ships to Jira. The frontend engine has no configurable depth cap; it iterates to a fixed point with MAX_SWEEPS = allKeys.length + 2.

TTLs, intervals and caches

ThingValue
Presence roster entry90 seconds (TTL_MS), pruned on every beat, leave and read
Draft retention24 hours, or immediately once flagged stale — both handled by cleanupExpiredDrafts, run by the scheduled consumer after each plan refresh
Write lock5 minutes
Frontend polling60 seconds (POLL_INTERVAL in static/ppm-ui/src/utils/constants.js, reduced from 10 s to save KVS reads)
Draft autosave60 seconds (AUTOSAVE_INTERVAL, defined in static/ppm-ui/src/components/plans/PlanView.jsx — not in constants.js)
Date-edit debounce300 ms (EDIT_DEBOUNCE)
JQL field-name cache1 hour, up to 150 clause names — checked in code, no KVS TTL
AI result cache10 minutes — checked in code, no KVS TTL
AI monthly cap500 billable calls (DEFAULT_MONTHLY_CAP); a cap of 0 disables the check
AI daily cap100 billable calls (DEFAULT_DAILY_CAP)
AI issues per request120 (CAP in the frontend buildPlanSummary), summaries truncated to 90 characters, up to 8 predecessors listed per issue

Plan size guidance

There is no hard cap on issues per plan in the code — the ceiling is practical, and it is set by the three budgets above. The timeline switches to windowed row rendering above 150 rows (VIRTUALIZE_THRESHOLD in GanttChart.jsx); below that every row is rendered, which is unchanged behaviour for normal plans. On the internal 5,300-issue performance plan the measured figures were: full index 42.2 s (inside the 900-second consumer budget), DOM nodes 94,920 before virtualization and 2,115 after, first paint 2.1 s → 1.07 s, and a later dependency-edge cull took rendered edges from 2,200 to 31 on an edge-dense variant. Plans in the low thousands of issues are comfortable. What actually hurts long before issue count does is the number of PLANS: the hourly refresh touches every plan, and while the issue-updated trigger now narrows itself through the plans:scope reverse index (an early exit when no plan covers the updated issue's project), it still reads an index per candidate plan on a site where many plans share the same projects.

LimitTwo limits that surprise people. First, the plans:list key is a single monolithic value updated by read-modify-write, and Forge KVS has no compare-and-set — two plans created at the exact same moment can lose an update. This is why the orphan-cleanup tool refuses to act on an empty registry (see the next section). The write lock has the same weakness and mitigates it with a unique token written then re-read to confirm the winner. Second, setLinkLag writes, re-reads and re-applies its own edge up to 4 times: edits to DIFFERENT edges converge, edits to the SAME edge are last-write-wins.

65Who can see and change what, and how data is deleted

Plan-level roles, the server-side gates that enforce them, the admin-only destructive actions and their refusals, and exactly what deleting a plan removes — and what it leaves behind.

Plan roles (src/services/permissions.js)

RoleHow you get itCan do
adminJira ADMINISTER permission, or an explicit plan member with role admin, or defaultAccess: adminEverything on the plan — and, via the Jira ADMINISTER route only, on every plan. A plan member with role admin, or admin via defaultAccess: admin, holds the role only for that plan: resolveRole is evaluated per plan and reads membership and default access from that plan's own meta
ownerYou created the plan (meta.createdBy)View, edit, apply, delete, manage members
editorAdded as a member with role editor, listed in the legacy editors[] array, or covered by defaultAccess: editorView, edit dates, save, apply
viewerAdded as a member with role viewer, or covered by defaultAccess: viewerView only
noneNot a member and defaultAccess is noneCannot see the plan in the list at all
NoterequireCapability resolves the base role first and only pays for the extra checkIsAdmin Jira call when that base role is insufficient — so the common owner/editor case costs no round-trip. A legacy plan that has an editors[] array but no defaultAccess field falls back to viewer for non-members, not none.

Where the roles are actually enforced (not just hidden in the UI)

  • listPlans resolves each plan's role and filters out any the caller cannot view, so a private plan does not appear in anyone else's list.
  • startWrite calls requireEdit before acquiring the write lock — and since writeChunk refuses to run unless the caller HOLDS the lock, a viewer cannot write even by calling the resolver directly.
  • deletePlan calls requireDelete (owner or admin only).
  • addMember, removeMember, updateMemberRole and updatePlanAccess all check canManagePermissions (owner or admin).
  • Every MUTATING admin resolver — field config, engine config, calendars, holidays, field auto-detection, orphan cleanup, purge — is wrapped in an adminOnly guard that requires Jira ADMINISTER. checkIsAdmin is fail-closed: any error returns false, so a Jira hiccup denies rather than allows. Admin READS (getFullConfig, getHolidays, getWorkingDaysConfig, getFieldConfig, getEngineConfig, previewOrphanedPlans) are left ungated because they expose only app configuration the settings screen already shows.
  • New plans default to defaultAccess: 'none' — private to the creator until members are added.
CarefulGOTCHA — role checks gate the plan LIST, editing, applying, deleting and permission management. They are not applied individually to every read resolver: getPlan, getAllIssues, getIssues, getBaseline, getPlanSchedule and the presence resolvers take a planId and answer, with no capability check. So a user with app access who already knows a plan id can read that plan's data, and the schedule/baseline/milestone writers (savePlanSchedule, addPlanHoliday, setBaseline, updatePlan) are likewise ungated. Combined with the fact that indexing runs as the app rather than as the viewer, the honest guidance is: plan roles are an organisational boundary, not a confidentiality control. If a project's issues must not be seen by someone, do not put them in a plan that person can reach.

Destructive actions and their safety rails

ActionWhat it deletesGuard
Delete planmeta, index, deps, the drafts REGISTRY, the lock, and every shard from 0 to meta.shardCount, then removes the entry from plans:listOwner or admin (requireDelete)
Clean up orphaned plansdeleteAllPlanData for each orphan, then a prefix sweep of every remaining p:{planId}: key — this is the only path that reaches the leftovers listed belowJira admin; plus two refusals described below. A read-only previewOrphanedPlans shows the list first
Purge all datadeleteAllPlanData for every registered plan, then empties plans:listJira admin only; there is no undo
CarefulA closed gotcha, worth knowing the shape of: deleteAllPlanData used to erase only meta, index, deps, drafts-registry, lock and the shards, leaving the per-user draft keys p:{planId}:d:{accountId}, the plan schedule p:{planId}:sched, the baseline p:{planId}:bl and the presence roster p:{planId}:pres behind as orphans. It now deletes those keys explicitly — plus the plan's plans:scope entry — and finishes with a residue sweep of everything still under p:{planId}:, so deleting a plan (and purging all data, which calls the same function) is a complete erase. The one caveat: the residue sweep is best-effort — if it fails, what is left behind is at worst what every delete used to leave, and the orphan cleanup remains the reclaimer of record for that case.

Why orphan cleanup is deliberately paranoid

The orphan finder feeds a delete, so it is conservative in two specific ways, both implemented as a pure, unit-testable decision in src/services/orphan-guard.mjs. First, a candidate is confirmed with a strictly-consistent per-plan getPlanMeta read before it is reported — the kvs.query used to enumerate keys is only eventually consistent, and plans:list is a single monolithic key written by read-modify-write with no compare-and-swap available, so one lost update would make every real plan look orphaned and this function is what would then destroy them. A plan with live metadata is not an orphan however the registry looks. Second, when called destructively (throwIfRegistryEmpty) it THROWS outright if the registry is empty while KVS still holds plan keys: that combination is the signature of a broken registry, not of orphaned data, and the correct response is to repair it, not to delete. The read-only preview does not pass that flag, so it still reports.

Data lifecycle, briefly

  • Drafts: the writer's own draft is deleted on a successful Apply; other users' drafts that touch the same issues are flagged stale with a reason; cleanupExpiredDrafts then deletes anything older than 24 hours OR already flagged stale, and it runs from the scheduled consumer after each plan refresh.
  • Presence entries: expire 90 seconds after the last heartbeat, and are pruned on every beat, leave and read of the roster.
  • Write locks: expire 5 minutes after the last refresh; an expired lock can be taken over by another writer, and the acquirer confirms ownership by re-reading its own token.
  • AI caches and meters: caches are treated as stale after 10 minutes but are never deleted (no KVS TTL); usage meters are per month and per day and hold call counts, token totals and a per-feature split only — no prompt text and no plan content.
  • Shards left behind by a plan that shrank are deleted on the next save — the loop deletes from the new count up to the recorded meta.shardCount. Shards that predate that behaviour sit ABOVE the recorded count, and no path reclaims them while the plan is registered: the next save's loop never reaches them, and the orphan cleanup only ever considers plans that are absent from plans:list and whose per-plan meta read returns nothing. They are reclaimed only once the plan is deleted, when deleteAllPlanData's residue sweep picks up everything left under p:{planId}:.
TipCost is a design constraint here, not an afterthought, and it explains several choices that otherwise look odd: Forge bills function duration and KVS bytes, and KVS writes cost roughly twenty times reads. That is why the hourly refresh writes nothing when the content hash matches, why the frontend polls every 60 seconds instead of every 10, why the getAllIssues resolver clears its map when finished, and why an old orphan-cleanup code path that re-ran a full KVS query per orphan — downloading every shard of every plan, because KVS queries have no keys-only mode — and discarded the result was removed.

66Before you troubleshoot: where a change actually lives

Almost every support question about LeanZero Management is really a question about which of the three storage layers a change is sitting in — the browser, the plan's own storage, or Jira.

An edit you make on the timeline does not go to Jira. It travels through three separate layers, and each has its own button, its own failure mode and its own way of being lost. If you can say which layer a change is in, you can answer most tickets without opening the code.

The three layers

LayerWhat holds itHow it gets thereHow it is lostWhat the UI calls it
1. Browser stateReact state in the open tabAny drag, resize, date edit, duration edit, buffer toggle, staged link or staged reorderClosing the tab before the 1.5-second draft autosave firesthe bar moves; the Apply badge counts up
2. Plan storage (Forge KVS)Issue shards p:{planId}:s:{n}, plus a separate per-user draft at p:{planId}:d:{accountId}The shards are written by the Save button and by the 60-second autosave. The draft is written separately, 1.5 seconds after you stop editing.Discard All, Apply, or a draft older than 24 hours"Save (N)" / "Saved" in the toolbar
3. JiraThe issue's own fieldsApply, and only ApplyNothing — a write to Jira is permanent as far as this app is concerned"Apply N changes"
CarefulSave and Apply are not two steps of one action. Save writes to the plan's own storage and never touches Jira; Apply writes to Jira. A plan can sit for weeks with saved-but-unapplied dates, and that is a legitimate state — it is how you model a replan before committing it.

Two automatic writes that do NOT wait for Apply

  • Plan Protection reverts a date change made outside the app and posts a comment on the issue. It is enabled on every plan by default (protectionEnabled: protectionEnabled !== false in createPlan) and the create wizard never sends the flag, so every plan created through the UI has it on. There is no toggle for it anywhere in the app's UI — it can only be changed via the updatePlan resolver. It fires ONLY when the change included a START date: the handler looks for a start-date entry in the changelog and returns without doing anything if there isn't one, so a due-date-only edit made in Jira is never reverted. The revert itself writes to the start field the app is CONFIGURED to use (config.fields.startDate, resolved lazily), falling back to customfield_10015 only when nothing is configured.
  • First index auto-creates the custom fields it needs, but only when the field config has not already been resolved (areFieldsConfigured()). If no field named Duration / PPM Duration exists, it creates a number (float) field called "PPM Duration"; if no Buffer / PPM Buffer exists, it creates a select field called "PPM Buffer". It then tries to add those fields to the edit screen of every project in the plan's sources, plus the Default Screen as a fallback. The fields stay in the Jira instance afterwards.

Where the draft fits

While you have unsaved edits, the app writes a draft to p:{planId}:d:{accountId} 1.5 seconds after you stop editing, and deletes it once unsaved edits drop to zero. On load — after the save baseline is established, so the restored edits still read as unsaved — the draft is overlaid back onto the freshly-indexed issues and the plan re-settles through the cascade. This is why a re-index does not lose your work, and why a stale draft (one marked stale after someone else applied over the same issues) is deliberately not overlaid at all: applyDraftOverlay returns the issues untouched when draft.stale is set.

67Reading the timeline: what each bar is telling you

Bar colour is two independent signals, not one, and misreading them is the most common cause of a "the Gantt is wrong" ticket.

The FILL says what an issue is. The OUTLINE says how it relates to Jira. They are computed separately and can appear in any combination — an amber buffer bar with a violet critical-path outline is a normal, meaningful state. (These used to be one if/else chain that tested "matches Jira" first, which meant the Buffer, Exhausted and Done fills were unreachable on any plan nobody had edited yet; that is fixed, and six colour assertions in the visual suite now lock it.)

Fill — what the issue is

FillMeaningExact rule
Slate summary bar with bracket endsParent rowthe issue is referenced as parentKey by another issue in the plan. Drawn as a 14px-tall line with two bracket end-caps rather than a filled bar.
AmberBufferbuffer === 'Yes'
VioletExhausted bufferbuffer === 'Yes' && Number(duration) === 1 && startDate === dueDate
Green-blue at 50% opacityDonestatusCategory === 'done'
BlueEverything elsethe default

Outline — how it relates to Jira

OutlineMeaningApplied when
noneSynced — matches what Jira holdsstart and due both equal _original, and — only where BOTH sides have a duration — the duration equals _original.duration too. Note the buffer flag is NOT part of this test: a buffer-only change counts toward the Apply badge but leaves the bar un-outlined.
Solid amber, 2px (#d97706)Draft — unapplied editany of those differ from _original; also any issue that had no Jira baseline and now has staged dates
Dashed light blue (#60a5fa)Cascaded — just moved by the chainthe issue is in cascadedKeys from the last recalculation (highlight clears after 800ms). Applied after Draft, so a cascaded issue shows the dashed outline even when it is also a draft.
Solid violet (#7c3aed)Critical — zero slackcritical-path view is on and the issue is on the critical path; applied last, so it wins over Draft and Cascaded
NoteParents and milestones never take an outline at all — the bar's outline is forced to none for both, so a staged milestone does not show the amber draft ring. Read its state from the Apply badge or the Table view instead.

Shapes

ShapeMeaning
Violet diamond (green when Done)Milestone. A milestone is DECLARED, not inferred: startDate === dueDate AND (the CURRENT duration is present and exactly 0, or the Jira issue type is "Milestone"). Parents and buffers are never milestones.
6px stubThe bar is clamped. getBarPos returned a negative width because the issue's start is after its due — a real data state, drawn as a minimum-width stub (Math.max(pos.w, 6)) rather than a negative smear.
A one-day-wide barThe issue has only ONE of the two dates. getBarPos falls back to a single pxPerDay column when start or due is missing.
No bar at allThe issue has NEITHER a start nor a due date. It still occupies a row.
"↑ PARENT-KEY" chip in the rowThe issue's Jira parent exists but is not in this plan, so the row sits at the top level. Clicking the chip opens the parent.
TipA one-day task is a bar, not a diamond. If a real task is drawing as a diamond, its duration field holds exactly 0. If a milestone is drawing as a one-day bar, its duration is missing (null) rather than zero — Number(null) is 0, so the predicate checks duration != null explicitly to keep a missing duration from being read as a declared zero.

68Troubleshooting: dates, dragging and the cascade

Symptoms where the schedule did not do what the user expected — nearly always the Iron-Clad rule, a buffer, working-day snapping, or a missing duration field.

SymptomLikely causeWhat to do
I dragged a task and it snapped back to a date I did not chooseIt has a predecessor. The Iron-Clad rule forces start = next working day after the latest predecessor's due date, advanced by that link's lag. A constrained successor cannot be freely dragged in either direction — the warning even names the direction ("Cannot move before/after").Drag the chain HEAD instead, and let the chain follow. If the successor genuinely should start later, set a lag on the link (the −/N/+ stepper on the link menu). If it should not be constrained at all, delete the link.
Several tasks jumped at once and I only see one toastMore than one issue was snapped in the same pass. Individual violation messages are buffered for 100ms and collapsed into N issues snapped to respect dependency rules.Open the Cascade Impact strip ("This change moved N issues") and press Details — it names every issue that moved and by how much, marking buffers as "absorbed" or "buffer exhausted".
I pushed a predecessor and its successor did not moveThe successor is a buffer with room left. A buffer holds its due date fixed and shrinks: start moves to the required start, duration = workingDaysBetween(newStart, fixedDue) with a floor of 1. The fixed due is taken from _original.dueDate where there is one. Downstream work does not move until the buffer is spent.Confirm the bar is amber and got shorter. Watch for it turning violet — at that point due = start, duration = 1, the buffer is exhausted and the chain will move on the next push.
A task landed a day or two off where I dropped itWorking-day snapping. Every date is pushed to the next working day of the plan's calendar. A move snaps the START in the direction of travel and rebuilds the due from the working-day span measured when you grabbed the bar.Check the plan's calendar under Schedule (the four presets are "Standard (Mon-Fri)", "Israel (Sun-Thu)", "UAE (Mon-Fri + Sat half)" and "6-Day (Mon-Sat)", plus custom patterns) and the plan's holidays. Note the UAE preset ships with the same working days as Standard — Mon–Fri — so choosing it changes only the label.
A move changed the task's lengthFixed defect (2026-08-13). A move used to shift both endpoints by the same number of CALENDAR days and validate only the start, so a drag across a different number of weekends silently changed the working-day span — and the frontend engine is authoritative on Apply, so the wrong duration reached Jira.Confirm the app version. If a move still changes a duration, capture the before/after dates of the dragged issue and escalate — this path now has a visual-suite test driving a real mouse drag at five distances in both directions.
An ordinary task turned into a diamond mid-dragFixed defect. The cascade used duration || 1 with no span fallback and then wrote dueDate = startDate, flattening any issue whose duration was null — and a flattened issue satisfied the old milestone predicate. Duration now resolves as: the stored duration (_original.duration first, then the live one) if > 0, else a declared exact 0, else the issue's own working-day span, else 1.Confirm the version. If it recurs, check whether the plan's project can store the PPM Duration field at all (see the next section) — a plan where every issue indexes duration: null is the case this defect lived in.
A milestone came back as a one-day task after ApplyThe project cannot store the duration field, so the declared 0 could not be written. Jira accepts a PUT for a field that is not on the edit screen and silently drops it.Add the duration field to that project's edit screen, or declare the milestone by issue TYPE instead — an issue whose type is "Milestone" with start === due is a milestone regardless of duration.
An issue sits at the top level instead of under its epic/storyIts parent is not in the plan. At index time an unresolvable parentKey is nulled (the tree builder, rollup and metrics all assume a parent resolves), but the fact is kept as orphanParentKey and the row shows the "↑ PARENT-KEY" chip.Widen the plan's source so the parent is matched. The plan's includeParents flag walks UP from every issue and pulls in missing ancestors, and it defaults ON for every plan created through the wizard (payload.includeParents !== false); a plan created before the flag existed has it unset and both index paths require a strict === true, so it stays off. There is NO UI control for it — the chip's tooltip tells you to "Turn on Include parents in the plan's sources", but no such control exists; it can only be set through the updatePlan resolver.
The bar is a tiny stub I cannot grabThe issue's start date is after its due date. The width is clamped to 6px on purpose.Fix the dates — either in the date editor (click the bar) or in Jira. A stub is data, not a rendering fault.
Rows keep re-ordering themselves and drag-to-reorder is disabledAuto-arrange is ON by default (gantt.autoArrange, a per-user display preference). It reorders rows so dependency lines stay short, keeping the hierarchy intact, and while it is on the row-drag handlers are inert. Row order is a view preference — Jira rank is untouched.Toggle Auto-arrange off in the Gantt control strip to get Jira rank order back and re-enable drag-reorder. The tooltip states which mode you are in.
A banner offers to "reorder to tidy dependencies"The plan has dependency arrows spanning more than 6 rows or pointing backwards, AND a proposed order measurably beats the current one on the cost function (backward × penalty + excess rows). If the proposal does not win, the banner stays silent rather than nagging. It is also suppressed entirely while Auto-arrange is ON, because the view is already showing the tidy order.Turn Auto-arrange off to see it. Press "Apply reorder" to stage rank changes (they go to Jira on Apply), or "Dismiss" — dismissal is remembered per plan by a fingerprint of the proposed moves.
I cannot draw a dependency — the line shows a red verdictThe drop was refused before staging. Three refusals exist: self ("Cannot link an issue to itself"), duplicate ("A already blocks B") and cycle ("…would create a circular dependency"), detected by a breadth-first walk of successors from the target.Press Escape to cancel the draw. To reverse an existing relationship, remove the old link first.
A cyclic chain produces impossible dates (start after due)A cycle is a logically impossible schedule. The engine is guaranteed to terminate (a sweep cap of allKeys.length + 2 plus a shared visited set in the successor walk), but dragging a node in a cycle pushes it further via its own feedback edge and dates can invert.This is documented behaviour, not a bug — it is asserted by cascade-core.cycle.test.js, which only requires termination and well-formed output. Break the cycle.
One edit moved far more than expectedA join node with two or more predecessors settles after all of them (the cascade walks a topological order via computeCascadeOrder, not a successor DFS), so a fan-in re-dates on the longest path.Open Explain — it names every new date and its reason ("X now starts Sep 7 — can't begin until Y finishes Sep 4"). The Impact strip also reports finish slip, buffers absorbed and how many were exhausted.
The finish date did not move even though the chain slippedSome other, unrelated task owns the project finish. A chain can slip several days without changing the plan's end date.Check the critical path (the zero-slack set) rather than assuming the chain you edited is critical.
LimitThe critical path is computed over LEAF issues only — parents are excluded before the forward/backward pass, so a summary bar is never critical. Task length prefers the stored duration and falls back to the calendar-day span between start and due, plus one. On a plan where nothing has a duration, a single long-span task can legitimately own the critical path while a long dependency chain carries slack. That is arithmetic, not a defect.

69Troubleshooting: applying to Jira, locks and conflicts

Everything between pressing Apply and the dates existing in Jira — including the two failure modes that look identical and are not.

What Apply actually does

  1. 1Review modal — you confirm (or un-tick) each staged date change, link, unlink and rank op. Then the client calls checkConflicts; if anything in the plan changed in Jira, the Conflicts dialog opens before any lock is taken.
  2. 2startWrite — checks you have edit rights on the plan (requireEdit), resolves your real display name server-side, then acquires the plan's write lock (5-minute TTL). If someone else holds it you get "Plan is locked by <name>".
  3. 3First chunk only — writeChunk at offset 0 re-checks that none of the affected issues changed in Jira since you started. On a conflict it releases the lock and refuses.
  4. 4Writes in chunks of 10 issues with a 250ms pause between issues INSIDE a chunk, and refreshes the lock after each chunk. There is no pause between chunks: the client loops writeChunk back-to-back, and the BATCH_PAUSE_MS = 2000 constant in write-resolvers.js is declared but never used. Every chunk re-checks that you still hold the lock; losing it aborts rather than writing over the new holder.
  5. 5Before each chunk the app reads /issue/{key}/editmeta for the FIRST issue in that chunk and drops any field that project cannot accept. A change whose remaining payload is empty is skipped, not counted as a failure.
  6. 6completeWrite — re-fetches the written issues and verifies Jira received the values that were INTENDED (not what the plan index holds, which is still pre-Apply at that moment). A field is only checked when it was both intended and settable.
  7. 7On a clean verify: re-index the written issues, delete your draft, mark other users' overlapping drafts stale, bump the plan version, release the lock.
SymptomLikely causeWhat to do
"Some changes did not persist in Jira — retry"Post-write verification found Jira does not hold what was written. The app deliberately does NOT re-index over the discrepancy (that would replace your intent with the stale Jira value), does not bump the version, and keeps your draft so the failed keys can be retried. The Apply dialog goes to "Apply failed".Look at the returned failedKeys. The usual cause is a field that is not on that project's edit screen. Fix the screen configuration, then Apply again — nothing was lost.
Dates will not write on one particular projectThe field is not settable there. Before each batch the app reads /issue/{key}/editmeta and drops any field the project cannot accept, because a single unknown field makes Jira 400 the ENTIRE PUT and silently kills the date write with it.Add the field to that project's screens. Team-managed projects do not use screen schemes at all, so this is where it bites most often.
Dates write for most issues in a batch but fail for oneThe settable-field probe runs once per chunk of 10, using the FIRST issue in that chunk. If a plan spans projects with different screen configurations, a field that is settable on the chunk's first issue is still sent for the others and Jira rejects their PUT.Keep a plan within one screen configuration where possible, or add the app's fields consistently across the plan's projects. Failed issues stay staged — Apply again after fixing.
A change that touches only the duration reports as a no-opCorrect behaviour. If the only field in a change is one the project cannot store, the filtered payload is empty and the write is skipped (continue) rather than counted as a failure.Nothing to do. If the duration genuinely needs to reach Jira, the field must be settable.
"Applied N · M couldn't be written to Jira — still staged, Apply again to retry"Some link, unlink or rank changes were refused — a missing link type, a permission denial. This exact wording is the link/rank-only Apply path; when there were also date changes the message reads "M link/rank changes couldn't be written to Jira — still staged, Apply again to retry".They stay staged deliberately, so the user's connections are not silently lost. Fix the cause and Apply again.
"Conflicts Detected" dialogIssues in this plan were modified by someone else since you started editing. The dialog lists issue key, field label, and old value → new value.Three choices: Cancel; Re-index Plan (pull the newer data and redo the edit); or tick "I understand that my changes may override the external modifications listed above" and press Apply Anyway.
"Plan is being written" overlay with someone else's nameAnother user holds the write lock. The overlay shows their live progress ("X of Y written · Z%") and a countdown, "Plan unlocks automatically in mm:ss".Wait. The lock has a 5-minute TTL, refreshed after each chunk, so an abandoned write frees the plan within five minutes rather than forever.
"Write chunk failed" mid-Apply, and the dialog shows "Apply failed"Either the lock expired (a long pause, a very large write) or another writer took it over — the resolver returns lockLost: true with the internal message "Write lock lost — aborting", which the UI does not display. Writing stops immediately rather than risking last-write-wins corruption, and the client calls cancelWrite.Re-open the plan and Apply again. Already-written chunks are in Jira; the rest is still staged.
Apply succeeded but Jira shows no notification emailsBy design. Writes use notifyUsers=false (and overrideScreenSecurity=true).Nothing to do — mention it proactively when a team expects Jira notifications for date changes.
The Jira history shows the app, not the person who appliedAll Jira writes and all indexing reads go through api.asApp(), so history attribution always shows the app. The signed-in user's identity is used only for reads: "who am I" (/myself), the site-admin permission check (/mypermissions?permissions=ADMINISTER), and the source-editor metadata calls (JQL autocomplete and validation, approximate counts, board/project/filter lookup and search), which try api.asUser() first and fall back to asApp() on 401/403 so suggestions and counts reflect what the viewing user can actually see.Expected. If per-user attribution is a requirement, that is a limitation to raise, not a misconfiguration.
A date change made directly in Jira reverted itself and left a commentPlan Protection. If the change included a start date and the new start is earlier than latest predecessor due + lag, the app reverts the start (and the due, if it changed) and posts a comment naming the blockers, the latest predecessor due date, the earliest allowed start, your requested start and the plan name.Make the change in the plan view instead, where the chain re-dates properly. Protection is on for every plan by default and has no UI switch — if a customer needs it off, that is a plan-meta change. A due-date-only edit in Jira is never reverted.
"Discard All" did not stick — the change came back after reloadFixed defect. Discard used to revert only the view; once anything had advanced the plan's stored checkpoint (a manual Save, or just the 60-second autosave firing mid-edit), the shards still held the edit and a reload resurrected it. Discard now writes the reverted values back and deletes the draft.Confirm the version. If the persistence step itself fails you get "Discarded here, but the plan couldn't be updated — reload to check" — reload and check before assuming it worked.
CarefulThere is no undo for Apply. Discard only exists before the write. After a bad Apply the recovery path is: set the plan back to the dates you want and Apply again, using the baseline variance panel to see exactly what moved. Nothing in the app restores a previous Jira state for you.

70Troubleshooting: indexing, freshness, permissions and performance

Symptoms about the plan itself — empty plans, stale plans, missing plans, slow plans, and multi-user surprises.

SymptomLikely causeWhat to do
"No issues indexed" after creating a planIndexing has not run, or the source matched nothing. Indexing is asynchronous — the wizard hands the plan to a background queue (status flips to queued) with a 900-second budget rather than the ~25-second resolver limit. If the enqueue itself fails, the resolver falls back to running the index inline under that 25-second limit.Press "Index Now" in the empty state. If indexing failed you will see the toast Indexing failed: <message> and the plan's status is error with the message in statusMessage. The message comes straight from Jira — a bad JQL is the usual cause. Failures are deliberately NOT retried by the platform (the consumer never re-throws), because retrying a permanently bad JQL for the whole retention window is worse than failing once.
A plan that used to have issues now shows noneThe source stopped matching — a JQL edited, a board filter changed, a project archived. NEITHER path wipes the plan: the hourly refresh returns early with reason empty, and a full manual Re-index keeps the previous shards and sets statusMessage to "The last re-index matched 0 issues — the previous data was kept. Check the plan's sources." If the plan genuinely shows zero rows, that is a read problem, not an emptied index.Open the plan's sources and re-validate the JQL / board / project, then Re-index.
The plan looks stale — Jira has newer dataThe hourly refresh has four cheap ways to decide there is nothing to do: a 55-minute stale guard (deliberately under the hourly interval, so a just-reindexed plan is not redone by the next tick); a busy guard (status writing or indexing); a single approximate-count probe (<sources> AND updated > lastIndexedAt); and a content hash of the rebuilt issue set. When nothing changed it writes NOTHING at all — not even a timestamp. Both change gates fail OPEN: any error, bad timestamp or unprobeable source assumes "changed" and refreshes.Press Re-index for an immediate, forced refresh. Note that lastIndexedAt not moving is correct on an unchanged plan — it is the watermark the probe compares against, so refreshing it every hour would defeat the gate.
Board-sourced plans seem to refresh less reliably than JQL onesBoard sources cannot be expressed as JQL for the count gate, so buildChangeProbeJql returns nothing and the probe fails open. They fall through to the content-hash gate, which still catches real changes — it just costs a fetch to find out.No action; explain the difference.
A re-index appeared to wipe unsaved editsIt should not. Both the manual and the hourly path keep local edits and only notify: "This plan was re-indexed — your unsaved edits are kept." Unsaved work is also autosaved as a draft and overlaid back after the reload.If edits genuinely vanished, check whether another user applied over the same issues — that marks your draft stale, and a stale draft is not overlaid.
The same toast appeared several times, or shows "×3"Forge Realtime replays roughly the last 120 seconds of events to any client that (re)subscribes (replaySeconds: 120), so one completed re-index can be redelivered. Identical message+type pairs are collapsed into one toast with a "×N" counter, and duplicate index:done events are dropped by comparing the plan version.Nothing to do. Errors are never coalesced — every failure surfaces separately, because error events carry no version.
"Save (N)" appears on a plan nobody has editedKnown cosmetic transient on a cold load. The save baseline (kvsSnapshot) is only established once the plan's calendar resolver has returned and duration normalisation has settled; until then the unsaved-issues check falls back to "every issue with a start or due date". Measured on the LZPT bed as a phantom 37 — exactly the leaf count. It self-clears once the baseline settles and has no effect on Jira.Wait a few seconds, or reload. Do not press Save to "fix" it.
The Duration column is empty for a few seconds after openingDuration normalisation is calendar-gated — you cannot compute a working-day span without knowing the working days, so it runs only after the plan's calendar loads. On a cold start that resolver can be slow.Wait for it to settle. The settled value is the working-day span of start→due for any issue that has both dates — with one deliberate exception: an explicit duration of exactly 0 is left alone, because that is how a milestone is declared.
A plan is missing from someone's listPlan visibility. defaultAccess defaults to none (private) in the create wizard, and the plan list is filtered by resolved role: site/app admin → owner → explicit member → legacy editor → default access. A plan with no defaultAccess at all (pre-dating the field) falls back to viewer if it has a legacy editors array, otherwise none.Add them as a member on the plan's Permissions tab, or change the plan's default access to viewer/editor.
A user can see the plan but every editing control is missingThey resolved to viewer. Server-side gating is real but PARTIAL: Apply (startWrite), createDependency, deleteDependency, setLinkLag, rankIssue, recalculatePlan, recalculateFromIssue and deletePlan all call requireEdit/requireDelete. savePlanState (the Save button) is NOT gated — it is hidden in the UI only.Change their role to editor on the Permissions tab.
The Delete button is not visibleDelete is grouped with Save / Re-index / Apply and is only rendered in the Gantt and Table views — not on Dashboard, Schedule or Permissions. Deleting also requires owner or admin (canDelete), enforced both in the toolbar and in the deletePlan resolver.Switch to Gantt or Table first.
Switching the plan's calendar re-dated everythingExpected. Choosing a preset saves the calendar AND re-runs the cascade for the whole plan under the new working days, with the toast "Dates recalculated for new working day schedule". Those become staged changes like any other.If it was a mistake, Discard All before applying. Treat a calendar switch on a live plan as a scheduling change, not a display setting.
A very large plan is slow to openExpected up to a point. Measured on a 5,300-issue plan after row virtualization: index 42.2s, first paint 1.07s (2.1s before), roughly 2,115 DOM nodes (94,920 before). Row virtualization and dependency-edge culling both switch on above 150 rows and share the same row window; below that everything renders exactly as before.If a plan under 150 rows is slow, that is worth escalating with the issue count and the number of dependency links.
Storage feels full / an old deleted plan still has dataOrphaned plan data — KVS keys for a plan no longer in the registry.Use the admin Maintenance tab: Scan reports orphan ids, Cleanup deletes them. Only Cleanup is admin-gated server-side (Jira ADMINISTER, fail-closed); Scan is a plain resolver with no permission check — admin-only only in the sense that its button lives on the admin page. It is read-only, so the exposure is a listing, not a deletion. The lost-registry refusal belongs to Cleanup, not Scan: cleanup calls the orphan finder with throwIfRegistryEmpty: true and refuses outright when the registry is empty but plan data exists, because that is the signature of a lost registry rather than orphaned data, and deleting on that reading would destroy the whole site's plans. Scan runs without that guard and simply reports — every candidate is re-confirmed with a strictly-consistent per-plan meta read before deletion, because the id list comes from the eventually-consistent kvs.query, so plans whose meta survives are filtered out and a lost registry shows up in Scan as zero orphans, not a list of all plans.
TipWhen a whole regression suite of symptoms appears at once on a test instance, suspect the DATA before the app. The project's own history records a 33-journey run coming back 12 red where none of it was code: an issue had been transitioned by hand, 39 staged edits had accumulated across runs, and LZPT-112→115 had been deleted and recreated in reverse.

71Messages, by exact wording

The strings that actually appear on screen, so a support ticket can be matched to a cause without guesswork.

Warnings and errors

MessageMeansAction
<KEY> must start at predecessor's due + 1 day (<date>). Cannot move before.The Iron-Clad rule pulled your edit back to the earliest legal start. The trailing word is before or after depending on which side of the required start you dropped it.Move the chain head, or add a lag.
N issues snapped to respect dependency rulesSame as above for more than one issue in a single pass (violations are buffered for 100ms and collapsed).Read the Cascade Impact details list.
Cannot create link: A → B would create a circular dependencyThe link was refused before staging.Remove the opposing link first.
A already blocks BDuplicate link.None.
Cannot link an issue to itselfSelf-link.None.
N issues couldn't be saved — not in the plan index yet. Re-index the plan.Those issues exist in your view but not in the plan's shard index — typically created since the last index. The shard write skips them.Re-index, then Save.
Save failed: <message>The plan-storage write failed. Nothing reached Jira.Retry; if it persists, capture the message.
Some changes did not persist in Jira — retryPost-write verification mismatch, surfaced through the Apply dialog's error state. Draft kept, version not bumped, index not overwritten.See the Apply section.
Applied N · M couldn't be written to Jira — still staged, Apply again to retryPartial link/unlink/rank failure on a link-or-rank-only Apply.Fix the cause; the failed items are still staged.
M link/rank changes couldn't be written to Jira — still staged, Apply again to retryThe same failure when the Apply also contained date changes.As above.
Write chunk failedA chunk of the date write did not return success — most often the write lock was lost or taken over. The write is cancelled.Re-open the plan and Apply again; written chunks are already in Jira.
Couldn't save lag: <reason> — reverted.The per-link lag failed to persist. This is the one immediate-persist path in the app, and it rolls the optimistic lag and its cascaded date shifts back — but only if your value is still the current one, so a rapid re-drag is never clobbered.Retry the lag.
Discarded here, but the plan couldn't be updated — reload to checkThe view reverted but persisting the revert failed.Reload and verify before assuming the discard held.
Indexing failed: <message>Indexing hit a hard error; plan status is error and the message is stored in statusMessage.The message is Jira's. Usually a bad JQL or a permission gap.
Couldn't create the plan — check the source (JQL / board / project) is valid and try again.Plan creation refused.Re-validate the source in the wizard.
Indexing may have failed. You can retry from the plan view.The wizard stopped waiting without a confirmed result.Open the plan and press Re-index.
Indexing is taking a while — it will finish in the background. You can open the plan now.Normal for a large plan; the background consumer has 900 seconds.Open the plan; it will populate.
Refusing to clean up: the plan registry is empty but N plan(s) still have data in storage.The orphan cleanup detected a lost registry, not orphaned data, and threw rather than deleting.Do NOT force it. Recover the plans.

Confirmations

MessageMeans
Saved N issues to planWritten to plan storage. NOT to Jira.
Successfully wrote N issuesThe date half of an Apply completed and verified. The progress dialog's own header reads "Applied — N written".
Written with N verification warning(s)completeWrite returned success but reported mismatches in its verification payload.
Applied N changesA link/unlink/rank-only Apply finished. (The date path uses "Successfully wrote N issues" instead.)
All changes discardedStaged edits reverted; on the Discard-All path the revert is also persisted back to the shards and the draft deleted.
This plan was re-indexed — your unsaved edits are kept.Fresh Jira data loaded around your unsaved work.
This plan changed in Jira. Your edits are kept — reload when you're ready to merge.An external change arrived while you have local edits. Nothing was overwritten — the app never auto-stomps local work.
<Name> applied changes to this plan. Your edits are kept — reload when you're ready to merge.The same, for another user's Apply rather than an out-of-app change.
<Name> applied changes — refreshing to the latest.Someone else applied and you had nothing local to lose, so the view converged.
This plan changed in Jira — refreshing to the latest.An external change with nothing local to lose.
Dates recalculated for new working day scheduleA calendar change re-ran the cascade across the whole plan.
Calendar set to "<preset>" / Custom calendar "<name>" appliedThe plan's working-day pattern changed.
Reordered N issues to tidy dependenciesThe rank-suggestion banner was accepted; the ops are staged, not yet in Jira.
No changes to save / No changes to writeNothing staged.

72Frequently asked questions

The questions customers actually ask, including the uncomfortable ones, answered from the code rather than the brochure.

Does the app change my Jira issues automatically?
No for scheduling. Every date, duration and buffer edit is staged locally and only reaches Jira when someone presses Apply. There are exactly two automatic writes: Plan Protection reverting an out-of-app START-date change that breaks a dependency (and commenting on the issue), and the first index creating the "PPM Duration" and "PPM Buffer" custom fields if no field with those names exists — that first index also tries to add those fields to the edit screens of the plan's source projects and to the Default Screen.
Can I undo an Apply?
No. There is no undo, no snapshot restore and no rollback of a Jira write. Discard All only works on staged changes. After a bad Apply the only route is to set the dates you want and Apply again; the baseline and variance panel tells you exactly what moved and by how much.
Who appears as the author of the change in Jira?
The app. Every Jira write goes through the app's own identity (api.asApp()), so the history shows the app, not the person who pressed Apply. The signed-in user is used only for reads — the "who am I" lookup, the site-admin permission check, and the source-editor metadata calls (JQL autocomplete/validation, approximate counts, board/project/filter lookup and search), which run asUser() first with an asApp() fallback. Writes also set notifyUsers=false and overrideScreenSecurity=true, so applying dates does not send Jira notification emails.
Does it work with team-managed projects?
Reading and indexing, yes. Writing is where it gets awkward: team-managed projects do not use screen schemes, so the PPM Duration and PPM Buffer fields are often not on their edit screens and the app's first-index attempt to add them (which walks issue-type screen scheme → screen scheme → edit screen) has nothing to walk. The app defends against this — it reads each issue's editmeta and drops fields the project cannot accept, because otherwise Jira 400s the whole update and the dates fail with them — but a field that is not settable simply will not be stored.
What happens if two people edit the same plan at once?
Both can edit. Each user's unsaved work is their own draft, and the drafts registry shows who is touching which issues. Applying is serialised by a plan-level write lock with a 5-minute TTL. Before the first chunk the app re-checks that nothing changed in Jira since you started; if it did, you get the Conflicts dialog. After a successful Apply, other users' drafts that overlap the written issues are marked stale, and a stale draft is not silently overlaid onto their view.
Is my data sent anywhere outside Atlassian?
No. The app runs on Atlassian Forge and its manifest declares no external fetch permissions at all, so it cannot call a non-Atlassian endpoint. All app data is stored in Forge app storage (the key space plans:list, p:{planId}:* and cfg:*). The optional AI features call Atlassian's hosted LLM via @forge/llm (the manifest declares the claude model family; model id claude-haiku-4-5-20251001, temperature 0, forced tool call), not a third-party API.
Are the AI features on by default?
No. They are gated behind an explicit enable flag that defaults off, plus spend caps — 500 calls per month and 100 per day by default — and a 10-minute result cache keyed by plan, account and an FNV-1a hash of the plan summary, so an unchanged re-review is free. Only round-trips that report token usage are metered. Usage is split per feature (review / assess / jql) and shown in the admin Maintenance tab, along with the enable switch and the cap fields.
What happens if I uninstall the app?
Your Jira issues keep whatever was applied — dates, durations and buffer values already written stay exactly as they are. The custom fields the app created ("PPM Duration", "PPM Buffer") remain in the instance. Everything else — plans, sources, drafts, baselines, calendars, permissions — lives only in Forge app storage. There is no export of a plan definition, so reinstalling means recreating plans. If you want the data gone deliberately, there is a "Purge Storage" button (resolver purgeAllData, Jira-administrator only) — but it is NOT on the admin Maintenance tab; it only appears in the plan list's error state, under the failed-to-load message.
Does the app reorder my backlog?
Only if you ask it to and then Apply. Row order on the timeline is a view preference by default (Auto-arrange), and it does not touch Jira. Actual rank changes are staged like any other change and written through the Agile rank API (PUT /rest/agile/1.0/issue/rank) on Apply.
Which Jira link type counts as a dependency?
"Blocks" by default, configurable in admin. The inward side is the predecessor ("is blocked by") and the outward side is the successor ("blocks"). Links of any other type are ignored entirely by the schedule.
Can I change which Jira fields the app uses?
Yes, in the admin "Field Mapping" tab. The defaults are customfield_10015 for start date, the system duedate, customfield_11581 for duration, customfield_12399 for buffer, customfield_12421 for buffer percentage and customfield_10019 for rank — but auto-detection at first index (which runs only while the field config is still unset) usually replaces them with whatever your instance actually has, which is why a real instance often ends up on a different duration field id than the default.
How large a plan can it handle?
5,300 issues has been measured end to end: 42.2 seconds to index, 1.07 seconds to first paint, roughly 2,115 DOM nodes on screen. Issues are stored 100 per shard. Row virtualization and dependency-edge culling engage above 150 rows; below that the timeline renders every row, which is exactly what small plans want. On a deliberately edge-dense variant of the same plan (2,199 Blocks links), edge culling took rendered edges from 2,200 to 31.
Does it level resources or assign people?
No. The workload view is read-only aggregation — it counts how many of an assignee's dated leaf issues overlap each week (up to 26 weekly buckets, top 12 assignees by total) and flags anyone whose peak week exceeds 3. It never reschedules anything and never changes an assignee.
Why is "% Complete" different from the number of Done issues?
It is effort-weighted, not count-weighted. Each leaf contributes its working-day duration, scored 100% for a Done status category, 50% for in-progress and 0% for not-started. One long untouched task can drag the figure far below the done-issue count — on the project's own test bed the warm, settled weighted number is 14 where the count average is 22, because a 42-day 0%-done task dominates the denominator. (On a COLD load, before durations normalise, it reads 22 — the two agree only until the durations settle.)
How is the plan health score calculated?
round(100 × (0.35 × on-time + 0.25 × buffer health + 0.25 × low risk + 0.15 × progress)), clamped to 0–100. On-time = 1 − overdue/leaf count. Buffer health = the average remaining health of every buffer, or 1 if the plan has none. Low risk = 1 − red-band leaves / scored leaves, or 1 if none are scored. Progress = 0.5 + 0.5 × (%complete/100). Because progress never falls below 0.5, the practical floor of the score is 8, not 0. Hovering the ring shows each component and its weight; the label reads Healthy at 75+, At risk at 50+, Critical below.
How is an issue's risk band decided?
score = slip×4 + depth×3 + (overdue ? 30 : at-risk ? 15 : 0) + depletion×25, rounded and clamped to 0–100; a Done issue scores 0. Bands: 60+ red, 30+ amber, below 30 green. "Depth" is the longest predecessor chain behind the issue, "slip" is days later than its _original due date (negative slip counts as zero), and "depletion" is the fraction of a buffer's original duration that has been consumed. Leaves only.
What counts as overdue versus at risk?
Open leaf issues only, measured in UTC days. Overdue = due date before today. At risk = due within the next 5 days inclusive. On track = due more than 5 days out, or no due date at all. The three buckets are a complete, non-overlapping partition of the open leaves.
Can I switch off the hourly background refresh?
Not from the UI. It is designed to be nearly free on idle plans: a count probe and a content hash mean an unchanged plan writes nothing at all, not even a timestamp. Plans are fanned out as one queued event each (batched 50 per push), so no single invocation has to refresh the whole fleet; if the enqueue fails entirely and there are 25 plans or fewer, it falls back to an inline serial pass, and above that it skips the tick rather than half-finishing.
Does the app support lag or lead between tasks?
Positive lag only, in working days, per link. Jira issue links carry no lag field, so the value is stored app-side in the plan's own lag map (p:{planId}:deps) and denormalised onto the issue at load. It is honoured by the cascade and by Plan Protection — a legitimately lagged link is not reverted for starting later than plain adjacency. Negative lag (lead) is not implemented; the setter clamps to Math.max(0, …). Because lag lives only in the app, deleting the plan loses it.
Can I export anything?
Yes — a CSV of the plan's issues (key, summary, type, status, startDate, dueDate, duration, buffer, assigneeName, parentKey) and a computed "Health report" CSV (key, summary, status, dueDate, riskScore, riskBand, baselineSlipDays, bufferHealthPct, bufferExhausted). Both are date-stamped, from the Dashboard toolbar.
Do buffers mean anything to Jira?
Only as a field value. Buffer is written back as a select value ({ value: 'Yes' }) like any other field. All the behaviour — holding the due date, shrinking, absorbing slip, turning violet when exhausted — happens in the scheduling engine, not in Jira.
What is the difference between a plan milestone and a milestone issue?
A plan milestone is a target date you type into the plan (max 24 per plan, name capped at 80 characters, date must be YYYY-MM-DD or the entry is dropped). It is not a Jira issue, is never written to a ticket and is not part of the cascade — it is a labelled marker the timeline draws and the AI assessment reasons about. A milestone ISSUE is a real Jira issue that is a single point in time, and it schedules like anything else.

73Glossary

The vocabulary the app uses on screen, defined as the code defines it.

Applied
Written to Jira and verified. The Apply button reads "Apply N changes" and only renders when N is above zero and you are in the Gantt or Table view.
Baseline
A frozen snapshot of {startDate, dueDate, duration} per issue, stored separately at p:{planId}:bl with a takenAt timestamp, capped at 2,000 issues. Display-only: it drives the ghost bars (labelled "WAS HERE") and the variance table and never enters the cascade or the write path. It is NOT the same thing as _original, the Jira-diff snapshot the Apply flow uses.
Buffer
An issue flagged buffer = Yes. It behaves as a shock absorber: when a predecessor pushes into it, its due date is held fixed (at _original.dueDate where there is one) and its duration shrinks instead, so downstream work does not move. Drawn amber.
Cascade
The re-dating pass that runs after every edit. It settles the edited issue against its own predecessors, then walks the affected issues in topological order (computeCascadeOrder: a BFS affected set plus a Kahn ordering) — not a successor depth-first walk, which is what used to leave a join node settled against a stale predecessor — and finally rolls parents up. Its output is the set of issues that moved.
Cascaded
An issue that moved because of someone else's edit rather than its own. Drawn with a dashed light-blue outline for 800ms; the timer is cancelled and restarted by the next cascade, so two quick edits no longer blank each other's highlight.
Critical path
The zero-slack set among LEAF issues, from a forward and backward pass over the dependency graph weighted by duration — falling back to the issue's calendar-day span plus one where no duration is stored, without which every duration-less task collapses to length 1 and the path degenerates. Drawn with a violet outline; toggling the view dims everything else.
Draft
Two related things. (1) The bar state: an issue whose dates (or duration, where both sides have one) differ from what Jira holds, drawn with a solid amber outline. (2) The stored object at p:{planId}:d:{accountId} holding that user's unsaved changes, autosaved 1.5s after editing stops, restored on load, deleted when edits reach zero or on Apply, and garbage-collected after 24 hours.
Exhausted buffer
A buffer with nothing left: buffer = Yes, duration exactly 1, start equal to due. Drawn violet with a pulse. Past this point the buffer stops protecting the chain and further slip passes straight through.
Iron-Clad rule
The app's one non-negotiable scheduling constraint: a successor starts on the next working day after its latest predecessor's due date, advanced by that link's lag (the maximum over all predecessors of the lag-adjusted required start). It governs the cascade, the drag behaviour and Plan Protection alike.
Lag
Extra working days inserted between a predecessor's finish and a successor's start, per link. Stored by the app in p:{planId}:deps (Jira links carry no lag) and honoured by both the cascade and Plan Protection. Positive only — the setter clamps negatives to zero. This is the one edit that persists immediately rather than waiting for Save.
Milestone
A declared point in time: start === due AND either a present duration of exactly 0 or an issue type of "Milestone". Never a parent, never a buffer. It is declared, not inferred — the old "any single-day task" rule turned ordinary tasks into diamonds whenever a cascade collapsed them.
Orphan
Used for two different things. An orphan ISSUE is one whose Jira parent is not in the plan; its parent link is nulled so the tree can be built, but the original key is kept as orphanParentKey and shown as a chip so the row can explain why it sits at the top level. Orphan PLAN DATA is storage keys belonging to a plan that is no longer in the registry, cleaned up from the admin Maintenance tab.
Plan milestone
A user-typed target date stored in the plan's metadata (max 24, name max 80 characters, date must be YYYY-MM-DD; anything else is silently dropped by sanitizeMilestones). Not a Jira issue, never written to a ticket, not part of the cascade — a labelled marker on the timeline.
Protection
Plan Protection. When enabled, a date change made outside the app on an issue with predecessors is validated against the Iron-Clad rule; a violating START date is reverted (along with the due date if that changed too) and an explanatory comment is posted naming the blockers, the latest predecessor due date, the earliest allowed start, the requested start and the plan. Enabled by default on every plan created through the wizard, with no UI switch. A change that touched only the due date is not validated.
Rollup
A parent's dates derived from its children: start = the earliest child start (snapped to a working day), due = the latest child due, duration = the working-day span between them. Recomputed for every ancestor of anything that moves.
Shard
A storage bucket of 100 issues at p:{planId}:s:{n}, with an index at p:{planId}:idx mapping issue key to shard number. Sharding exists because a single stored value has a hard size limit (240KiB); the index is also why an issue created since the last index cannot be saved until you re-index.
Slack
The room an issue has before it starts pushing the plan's finish. Zero slack means critical. The Explain narrative uses it in its plain-English sense too: a buffer that absorbs a push has spent slack even though no downstream date moved.
Staged
Changed in the app but not yet written to Jira. Dates, durations, buffers, new links, removed links and reorders are all staged — including deletions of existing Jira links, so a mis-click is reversible. Anything Jira refuses on Apply stays staged so it can be retried rather than silently lost.
Synced
The bar state with no outline: the issue's start and due match what Jira holds, and — where both sides carry one — so does the duration. The buffer flag is not part of this comparison, so a buffer-only change shows in the Apply count without changing the bar.
Variance
Current dates measured against the baseline: per-issue slip in days, plus the plan-level net, slipped, ahead and tracked counts on the dashboard (top 6 rows by absolute slip).
Working calendar
The set of weekdays that count as working days for a plan, plus its holidays. Four presets ship — "Standard (Mon-Fri)", "Israel (Sun-Thu)", "UAE (Mon-Fri + Sat half)" and "6-Day (Mon-Sat)" — and custom patterns are supported. The UAE preset's working days are [1,2,3,4,5], identical to Standard. Every duration, every snap and every cascade step is measured in these days, so switching the calendar re-dates the plan.
Write lock
A plan-level lock held for the duration of an Apply, at p:{planId}:lock, with a 5-minute TTL refreshed after each chunk of 10 issues. Acquisition stamps a unique token and re-reads to confirm ownership, because Forge KVS has no compare-and-set. Other users see an overlay naming the holder, their live progress and a countdown to automatic release.

74Every number in one place

The constants, defaults and thresholds a support engineer needs when a customer asks "how long should this take" or "why did it stop".

Writing to Jira

SettingValueWhy
Issues per write chunk10Stays inside Jira's 2-second burst window
Delay between issues250 ms (inside a chunk only)Safely under the 50 PUT/second burst limit
Pause between chunksnone in effectBATCH_PAUSE_MS = 2000 is declared in write-resolvers.js but never referenced, and the client loops writeChunk back-to-back. Treat the effective spacing as one resolver round-trip.
Write lock TTL5 minutesRefreshed after every chunk; bounds an abandoned write
Retry policy4 attempts, 2s exponential base, 30s cap, 0.7–1.3 jitterHonours Retry-After on 429; retries 5xx and thrown errors; never retries other 4xx
NotificationnotifyUsers=false (plus overrideScreenSecurity=true)Applying dates does not email watchers

Indexing and refresh

SettingValue
Background index budget900 seconds (the queue consumer, timeoutSeconds: 900); the inline fallback used when the enqueue fails has the ~25s resolver limit
Scheduled refresh intervalhourly
Stale guard55 minutes — deliberately under the interval
Change gatesapproximate count on <sources> AND updated > lastIndexedAt, then a content hash; both fail open
Queue fan-out batch50 events per push
Inline fallback ceiling25 plans — above that the tick is skipped rather than half-done
Issues per shard100
Jira page size100 issues per request (search and bulkfetch alike)
Summary truncation80 characters
Empty-source handlinga source set that matches nothing never empties a populated plan — the hourly path returns empty, the full index keeps the shards and sets a statusMessage
Failure handlingthe consumer never re-throws, so a permanently bad JQL is not retried for the whole retention window

Client-side timing

SettingValue
Draft autosave debounce1.5 s after editing stops
Save autosave interval60 s; the timer always runs but only saves when there are unsaved changes
Draft/lock poll interval60 s (POLL_INTERVAL)
Violation-toast buffer100 ms — collapses a multi-issue snap into one message
Realtime event replay window120 s on (re)subscribe (replaySeconds: 120) — the reason duplicate events exist at all
Cascade highlight duration800 ms
Row virtualization thresholdabove 150 rows (row window and dependency-edge culling switch on together, sharing one window)
Rank-suggestion gap threshold6 rows
Zoom levelsDay (21 days visible), Week (90), Month (270), Quarter (540); default Week

Storage and caps

SettingValue
Draft retention24 hours, plus immediate cleanup of drafts marked stale — run opportunistically by the queue consumer on the hourly fan-out path
Baseline cap2,000 issues per baseline snapshot
Plan milestones24 per plan, name capped at 80 characters, date must be YYYY-MM-DD
KVS value ceiling240 KiB per stored value (the reason for sharding)
AI monthly cap500 calls
AI daily cap100 calls
AI result cache10 minutes, keyed by plan + account + an FNV-1a hash of the summary
AI default statedisabled

Engine defaults

SettingDefaultNotes
Dependency link typeBlocksinward = predecessor, outward = successor
Max cascade depth10read only by the BACKEND chain-calculator
Max parent roll-up iterations5read only by the BACKEND engine
Max traversal depth15exposed in admin as "Max Dependency Graph Depth" — read by nothing at all
Max issues per traversal150exposed in admin as "Max Issues in Single Calculation" — read by nothing at all
Start date fieldcustomfield_10015overridden by auto-detection on first index
Due date fieldduedatesystem field
Duration fieldcustomfield_11581created as "PPM Duration" (float) if nothing matching exists
Buffer fieldcustomfield_12399created as "PPM Buffer" (select) if nothing matching exists
Buffer percentage fieldcustomfield_12421never auto-created
Rank fieldcustomfield_10019written through the Agile rank API, not as a field value
CarefulEvery setting on the admin "Calculation Engine" tab is inert for what the user sees. "Max Dependency Graph Depth" and "Max Issues in Single Calculation" are read by no code anywhere. "Max Cascade Depth" (10) and "Max Parent Roll-up Passes" (5) ARE read — but only by src/services/calculation/*, the backend engine, and the UI never calls its resolvers: the frontend cascade-core.js is what settles the plan and what Apply writes. The real bounds on a live plan are the frontend engine's fixed-point sweep limit (allKeys.length + 2) and its visited-set guard. If a customer has tuned these to fix a performance problem, they have not changed anything.

75What to collect before escalating

The specific evidence that turns a vague report into something reproducible, based on how this app's real defects were actually found.

Always capture

  1. 1The app version and the plan id.
  2. 2The exact on-screen message, verbatim — the wording maps to a single code path.
  3. 3Which layer the change was in: was it staged only, saved, or applied? "Apply N changes" and "Save (N)" in the toolbar answer this.
  4. 4The issue keys involved and, for a scheduling complaint, the predecessors of the issue that misbehaved.
  5. 5The plan's status and statusMessage (surfaced by getWriteProgress and the indexing-status poll) — error plus a Jira message is usually the whole answer.
  6. 6Whether the plan's project can store the duration and buffer fields (check the project's edit screen). This single fact explains a large share of write and milestone reports.

Diagnostic habits that have paid off here

  • A green write plus an "Apply failed" message means suspect the VERIFICATION baseline, not the write. That exact combination was a real defect: verification compared fresh Jira against the plan index, which still held pre-Apply values, so every successful write read as a mismatch.
  • When the UI contradicts code you can read, capture the browser console rather than theorising. A bar collapsing to zero width was an imperative style clear defeating React's no-op diff — invisible in a static reading of either file.
  • When a symptom appears in one place, look for the same rule elsewhere before fixing it. The milestone predicate existed in three copies that disagreed; the duration fallback existed in two engine paths that disagreed. Both produced user-visible weirdness that a single-site fix would have left half-alive.
  • A negative result is not proof until you have proved the query can see the thing at all. An empty count, an empty list or a 404 has authorised the wrong action before.
LimitThe app ships a web trigger for test state, but it returns 404 unless a harness secret is configured in the environment AND the caller presents a matching Bearer token — it does not exist in production and cannot be used to diagnose a customer's instance. Production diagnosis is Forge logs, the plan's status and statusMessage, and the messages listed in this section.

How it is built

A React custom UI talking to Forge resolvers, with plan data sharded across Atlassian's key-value store so a five-thousand-issue plan stays inside the platform's limits. Indexing runs asynchronously on a queue; an hourly refresh keeps plans in step with Jira and skips entirely when nothing relevant has changed.

The scheduling engine exists twice — once in the browser, where it has to be instant, and once on the server. A parity test suite settles the same plans through both and fails the build if they ever disagree, so what you preview is provably what gets applied.

Try LeanZero Management

Free while it is in beta. Install it from the Atlassian Marketplace and point it at a project you already run — it schedules the issues that are there, so there is nothing to set up first.

Get on MarketplaceBuild a Forge app