Jira 32,767 characters limit: find the description and comment data a JCMA migration lost
Gabriela Perdum
Author
15 min readSeptember 15, 2026
Key takeaways
END STATE: a reviewed JSON plan and a dry-run diff on your own instance before a single PUT, plus three issues opened in a browser to confirm the values are actually visible.
Cloud caps a description at 32,767 characters of SERIALIZED ADF JSON, not visible text. Measured: 32,670 visible characters in one paragraph hits the cap; the same text in short paragraphs reaches only 13,938 at 8,000 visible.
Detect truncation on JSON.stringify(body).length === 32767 — an exact equality, because landing on that number by chance is vanishingly unlikely.
Custom field ids differ between instances. JCMA offers a serverId-to-cloudId mapping API; where it is not available or not trusted, this toolkit maps on NAME. When two candidates are equally valid, the tool must refuse and report rather than pick.
Security levels that fail to migrate make restricted issues MORE visible, not less — so verify that one by absence, as someone who should not see them.
A Jira migration reports success at the level of issues moved. It says nothing about the data inside them, a description JCMA truncated at the 32,767 characters limit or a field that landed in a duplicate twin, and the things inside them fail quietly enough that the person who finds out is a user, three weeks later, asking why half a runbook is missing.
This walks through finding two of those failures on your own instance and repairing them — with the discipline that matters more than either fix, which is that you read the plan before anything writes.
Note
Prerequisites
Node 18 or later. Written against Node 24.
A Data Center account that can read every project — DC is the source of truth for every repair here.
A Cloud API token that can write to them, and one .env shared across the tools.
A Cloud account with Administer Jira (global). The muter creates, assigns and deletes notification schemes, and project admin cannot do that; step 6 explains why.
Understand the two failure shapes before you go looking for either.
2
Detect truncated bodies on an exact serialized length, not on visible text.
3
Report before you plan
this is the phase everyone skips.
4
Read the plan, because a consistent plan can still be wrong.
5
Audit the plan as a table a second person can review.
6
Mute notifications before writing, and know how to restore them.
7
Apply, then verify in a browser rather than in a counter.
Step 1 — The two failure shapes
They fail differently, and a check for one will not find the other.
The field that arrived empty. Custom field ids differ between Data Center and Cloud, so the migration has to map them somehow. JCMA exposes a serverId-to-cloudId mapping API for a completed migration; this toolkit matches on name, for the engagements where that mapping was not available or not trusted. If two Cloud fields share a name — and they often do, because the migration itself helpfully created a (migrated)-suffixed duplicate — then the value can land in the twin. The field on your screen stays blank. Nothing errors, because writing to the other field succeeded.
The body that arrived truncated. On the engagements this toolkit came from, descriptions and comments arrived cut at exactly 32,767 characters, with nothing in the issue to say so. Atlassian's own pages disagree about what happens: its knowledge base describes an import error you are meant to fix on the server first, and MIG-1356 says the assistant truncates and writes a log line. Read your JCMA log either way; the detector below finds what the log did not.
That number is worth recognising on sight. 32,767 is 2¹⁵ − 1 — the largest value a signed 16-bit integer holds. When a limit is one below a power of two, it is a storage constraint rather than a product decision.
How you know it worked: you should be able to state which of the two you are hunting before you run anything. They need different tools and the detection for one is meaningless against the other.
Step 2 — "It exceeds the allowed limit of 32767 characters": detect the JCMA truncation on serialized length
Here is the part that makes truncated content hard to find, and it is the reason a naive search returns nothing.
On those engagements the cap fell on the serialized ADF JSON of the body, not to the rendered plain text. So a truncated description does not have 32,767 visible characters. It has 32,767 characters of JSON.stringify(adfBody), and how much visible text that represents depends entirely on how the content is marked up.
Measured, on one long paragraph versus the same text broken into short ones. This is not a shipped script; paste it into a file and run it:
js
1// probe.mjs2// Measures how many characters of serialized ADF a body costs, for the same visible text.3constpara=(t)=>({type:"paragraph",content:[{type:"text",text: t }]});4constdoc=(paras)=>({type:"doc",version:1,content: paras });5const one =doc([para("x".repeat(32670))]);6const many =doc(Array.from({length:100},()=>para("x".repeat(80))));7for(const[label, d, visible]of[["one paragraph", one,32670],["100 x 80-char paragraphs", many,8000]]){8const n =JSON.stringify(d).length;9console.log(`${label.padEnd(26)} visible ${visible} serialized ADF JSON length : ${n} ratio ${(n / visible).toFixed(2)}`);10}
bash
1node probe.mjs
text
1one paragraph visible 32670 serialized ADF JSON length : 32767 ratio 1.00
2100 x 80-char paragraphs visible 8000 serialized ADF JSON length : 13938 ratio 1.74
One long paragraph reaches the cap at 32,670 visible characters — near parity, because a single paragraph node carries very little JSON overhead per character. Break the same text into 80-character paragraphs and every one adds its own node structure: 8,000 visible characters now serialize to 13,938, a ratio of 1.74.
Extrapolate that and a heavily formatted description — tables, panels, lists, inline marks — can hit the 32,767 JSON ceiling at well under twenty thousand visible characters. Which is why searching for long-looking descriptions finds nothing and the issues that are actually truncated look unremarkable.
The detection is therefore on the serialized length, and it is an exact equality:
Not >=. Nothing can exceed the cap, so > would never match. (The README offers >= as a variant if your converter stopped on a node boundary; the shipped predicate is ===.) And === rather than a threshold because landing on exactly 32,767 by chance is vanishingly unlikely — a body that serializes to precisely the maximum was almost certainly cut there.
Scan one project first, and cap it while you are learning what a true positive looks like:
The repair this tool ships is not an in-place rewrite: --apply attaches a docx of the full DC body to the Cloud issue and leaves the truncated field as it is.
How you know it worked: open the flagged issue and confirm the body ends mid-sentence, often mid-word. A true positive is unmistakable. If a flagged issue reads as complete you should report that rather than repair it — you have found the rare natural 32,767, and attaching a 'recovered' docx to it would only confuse whoever reads it.
The other four shapes, briefly: duplicate custom fields, links, parents, worklogs
Two failures are enough to learn the method. There are more, and they are worth recognising because each one needs a different check.
Comments arrive mangled. Escape sequences survive the move as literal text, and a mention whose account did not map renders as @unknown. The comment is present, its length is right, and a count of migrated comments is correct — so nothing flags it. You find these by reading, or by searching for the artefacts.
Links and parents go missing wherever only one end migrated. A link needs both issues to exist on the target. Migrate a project whose issues link to a project migrating next week, and every one of those links is silently dropped. The issues are fine. The relationships between them are not, and a per-issue audit will never see it because the failure lives between issues rather than in them.
Attachments where the relationship, not the file, is lost. Same shape: the bytes are somewhere, the association is gone.
Security levels, which are the dangerous one. Covered in step 7, and repeated here because it is the only failure in the set where the reassuring number and the harmful outcome move together. Everything else in this list makes something missing. This one makes something visible.
Every tool here runs four phases: report → plan → audit → apply. The order is the safety property, and the first phase is the one people skip because it produces nothing but a file.
The report says what the tool thinks maps to what. That is where a wrong field pairing is caught — on ten issues rather than on ten thousand. Once you are past it, a wrong pairing is not a mistake you find, it is a mistake you deploy.
Because this toolkit matches on name (use the JCMA mapping API where you have it; the field-id tutorial linked above covers it), every tool has to handle duplicate names. The rule throughout is the one that matters most in this whole article:
When two candidates are equally valid, refuse and report.
When two same-named fields are equally valid the tool refuses and reports, and you pin the answer in a config file; the only tie-break it applies itself is field type, and that is logged in the report. That feels slower for about ten minutes and it is the difference between a repair and a second incident — because a guess that is wrong writes just as successfully as a guess that is right.
The four phases are literal subcommands, which makes the discipline hard to skip by accident:
How you know it worked: the report should name every ambiguous pairing explicitly, and you should have resolved each one in config before continuing. If the report is empty on an instance that has been migrated once already, be suspicious rather than pleased and go looking for a (migrated) suffix by hand.
Step 4 — Read the plan
bash
1node main/sync_custom_fields.js plan --projects ABC,DEF --limit200
The plan is a JSON file of every intended write. Nothing has touched Jira yet. You can also plan a single issue — plan --issue ABC-2080 — which is the cheapest way to check the tool agrees with you before you let it near a project.
Read it. Not skim it — open it, grep it, and look up three issues you know well to check that what it intends for them is what you would do by hand.
The failure this catches is specific and it is not a crash. It is a plan that is internally consistent and wrong: every row well-formed, every id resolvable, the whole thing executing perfectly and producing the wrong result. A progress bar cannot show you that. Twenty lines of JSON and one issue you understand will.
How you know it worked: pick an issue from the plan, open it on DC and on Cloud side by side, and confirm the planned value matches the DC value and the target field is the one on the Cloud screen. If you cannot do that for one issue, you cannot do it for ten thousand.
Step 5 — Audit the plan as a table
bash
1node main/sync_custom_fields.js audit
The audit phase renders the latest plan into a CSV and a Markdown table — one row per field per issue, with a set_missing / overwrite_diff summary — so a second person can review it without reading JSON. It makes no call to either instance; the root README's "seeded sample" wording describes an intent the shipped code does not implement.
It exists for the case step 4 is designed to catch but a human reading twenty rows might miss: the plan agrees with itself, and disagrees with the instance. Live state can have moved since the plan was built — someone edited an issue, an admin changed a screen — and a plan is a photograph, not a live view.
The toolkit also re-validates each row against live state at write time, so a plan built yesterday cannot act on a state that has since changed. That recheck is the only live check in the flow; the audit gives a human the plan to review, and the write-time check stops any individual row acting on a stale assumption.
How you know it worked: the audit CSV should be reviewable by a person who was not involved in building the plan. If it only makes sense to whoever ran it, it is a log rather than an audit.
Step 6 — Mute notifications, and know the way back
This step is operational rather than clever, and skipping it is how a repair becomes an incident that has nothing to do with data.
Writing to thousands of issues generates thousands of emails. Every watcher on every touched issue gets one, the toolkit's README warns the migration account gets throttled, and your inbox-facing colleagues find out about the repair before you have finished it.
So tools that write clone the affected project's notification scheme, remove the Issue Updated event from the copy, use the copy for the run, and restore the original afterwards. Every write already carries notifyUsers=false; the scheme clone is belt and braces. The original is snapshotted to disk first.
The part to internalise is the failure mode. If a run dies mid-flight — a crash, a token expiry, a laptop lid — the project is left on the stripped scheme. Notifications stay off, silently, and nobody notices until somebody asks why they stopped being told about their own issues.
There is a --restore-only-notifications mode for exactly this (some tools in the set spell it --restore-only; check --help). Run it first if a previous run was interrupted, before anything else, and confirm the project is back on its original scheme.
How you know it worked: after the run, open the project's notification scheme in the admin UI and confirm it is the original by name, not a clone. The snapshot on disk is what you compare against.
The first walks the plan and logs what each issue would receive, without the PUT — and also without the write-time recheck and without touching notification schemes, so it validates the plan rows, not the muting. Note that apply needs --apply as well — the subcommand alone will not write, which is a deliberate second lock on the only phase that can do damage. Then for real. --resume continues from the plan file after an interruption, and --retry-failed re-runs only the rows that failed, because re-running everything to catch twelve failures is how you generate a second set of problems.
For this tool, resuming is safe because the plan file records each row's status and the write-time recheck skips values already equal on Cloud; the tools that create objects (comments, links) additionally tag each one with its DC origin.
Then comes the part that is not automatable.
A green number is not a passing test. "4,801 values written" is not "the field is populated", and it is not even "the field is on the screen". A value written to a field that is not on the project's edit screen writes successfully, returns 200, and displays nothing at all.
So before you believe any run:
Open three issues in a browser and look at the field. Check the whole expected result rather than the first field that worked — one field populating while ten stay blank is a failure, not a partial win. And for anything visibility-shaped, verify by absence: log in as somebody who should not see a restricted issue and confirm they cannot.
That last one has a sting worth stating plainly. Security levels that fail to migrate do not make issues invisible — they make restricted issues more visible, because the restriction is what is missing. A count of migrated issues goes up. Exposure goes up with it. This is the one failure in the set where the reassuring number and the dangerous outcome point in the same direction.
How you know it worked: three issues open in a browser, every expected field checked on each, and one negative test performed as a user who should be excluded. Not a count.
Key takeaways
The truncation cap is on serialized ADF JSON, not visible text. Measured: 32,670 visible characters in one paragraph hits 32,767 JSON, while the same text in short paragraphs reaches only 13,938 at 8,000 visible. Searching for long descriptions will not find them.
Detect it on JSON.stringify(body).length === 32767 — exact equality, because nothing can exceed the cap and hitting it by chance is vanishingly unlikely. 32,767 is 2¹⁵ − 1, so it is a storage limit.
Report before you plan. A wrong field pairing caught in the report costs ten issues; caught after apply it costs ten thousand. When two candidates are equally valid the tool must refuse and report, never guess.
Read the plan and audit a sample. The dangerous failure is a plan that is internally consistent and wrong — it executes perfectly and produces the wrong result, and no progress bar will show you that.
Restore notifications before anything else if a run was interrupted, and verify the repair in a browser rather than in a counter. A value written to a field that is not on the screen returns 200 and displays nothing.
Attach an API spec to a Jira workflow validator: what the CogniRunner AI rule never reads