Your Jira filters survived the migration. That's the bug.
Key takeaways
- A saved filter stores its JQL as literal text. Nothing in it is a reference that a migration can follow.
- cf[10042] binds to whatever field occupies slot 10042 on the site you are querying, and the destination allocates that number from its own counter.
- Jira Cloud's strict validation checks fields, filters, projects and statuses. It does not check users, so a filter carrying a Data Center username saves clean and matches nobody.
- Both routes can copy every filter if you pick the "All filters" option. JCMA 1.12.12 and later rewrites ids in the JQL it migrates and reports what it could not resolve, though Atlassian warns that not everything gets reported. Cloud to Cloud's documentation says nothing about rewriting custom field ids, and Atlassian's tracker records project ids left unchanged.
- Rewriting ids to field names is not the safe option. Real tenants have duplicate field names: with two fields of the same type a name clause matches both, and with different types a value clause will not even save.
A filter that breaks after a migration is a good outcome. Someone opens it, sees an error, files a ticket, and you fix it. The one that costs you is the filter that opens fine, returns forty issues, and is wrong. Nobody files a ticket for that. It feeds a board, a dashboard, an SLA calendar or an automation rule, and it quietly reports the wrong number for a quarter.
I spent this morning measuring exactly how that happens, on a Jira Cloud site, with the REST API. Everything below with a number attached was run on 12 August 2026 against a throwaway Cloud site on the Free plan. I have flagged the handful of things I am inferring rather than measuring, and the one thing I could not test at all.
Correction, 13 October 2026: a reader's comment sent me back to this post, and parts of it were wrong. I wrote that JCMA leaves most standalone filters behind and that Data Center ids fail loudly on Cloud. Atlassian's page lists cross-project filters and filters shared with projects as migrated, JCMA has an option that takes every filter, and since version 1.12.12 it rewrites ids in the JQL it migrates, though Atlassian's own sources disagree on which ones. I also wrote that the parser will not tell you which of two same-named fields it picked. I re-tested that on the same site, and it uses both. I have corrected the key takeaways, both route sections, the comparison table, the duplicate-name section and the advice at the end, and added a note on /rest/api/3/search/jql. The re-tests ran on 13 October, and every other number is still from 12 August.
A saved filter is a string
This is the whole problem in one sentence, and it is worth proving rather than asserting.
I created a custom field, got back customfield_10924, and saved a filter against it:
curl -u "$EMAIL:$TOKEN" -X POST \
-H "Content-Type: application/json" \
"https://$SITE/rest/api/3/filter" \
-d '{"name":"probe","jql":"cf[10924] ~ \"alpha\" ORDER BY created ASC"}'
Then I renamed the field. Twice, as it happens, because I wanted to be sure the first result was not a fluke: "LZ Probe Alpha" became "LZ Probe RENAMED", then "LZ Probe TOTALLY DIFFERENT". After that I read the filter back:
stored jql : 'cf[10924] ~ "alpha" ORDER BY created ASC'
rename HTTP : 204
jql after rename : 'cf[10924] ~ "alpha" ORDER BY created ASC'
Unchanged, which is what you would expect. The interesting part is what still works. All three of these were run against the final state, where the field is called "LZ Probe TOTALLY DIFFERENT":
cf[10924] ~ "alpha" QUERYABLE
"LZ Probe RENAMED" ~ "alpha" NOT QUERYABLE
"LZ Probe TOTALLY DIFFERENT" ~ "alpha" QUERYABLE
So cf[10924] is not a pointer to a field called anything. It is a lookup of slot 10924 in whatever field table the site you are querying happens to have, performed at query time. The filter has no idea what lives there. It never did.
That is fine while the filter and the field table stay on the same site. A migration is precisely the event that separates them.
What the parser actually accepts
Before going further I had to correct my own notes, because two things I have repeated for years turned out to be wrong when I finally tested them.
The first is that customfield_10042 and cf[10042] are interchangeable in JQL. They are not, at least not on Cloud:
cf[10015] is not EMPTY ACCEPTED
customfield_10015 is not EMPTY REJECTED
Field 'customfield_10015' does not exist or you do not have permission to view it.
Both refer to the same field. Only one is a JQL token. You can see why from the field's own definition, which lists every clause name the parser will accept for it:
{
"id": "customfield_10015",
"name": "Start date",
"clauseNames": ["cf[10015]", "Start date", "Start date[Date]"]
}
customfield_10015 is not in that list. I checked all 188 custom fields on the site and not one of them advertises a customfield_N clause name. So if your rewriting script emits that form, every filter it touches gets rejected, which is at least loud.
The second correction is more embarrassing. I had it written down that Cloud's parser rejects Data Center's lenient syntax, specifically lowercase operators and unquoted values in an IN list. It does not:
| JQL | Result |
|---|---|
| labels not in (Test, TEST) | accepted |
| labels NOT IN ("Test", "TEST") | accepted |
| project = WFH and status = Done | accepted |
| status = In Progress | rejected, needs quotes around the multi-word value |
| issuetype in (standardIssueTypes) | rejected |
| issuetype in (standardIssueTypes()) | accepted |
Lowercase and, lowercase not in and bare single-token values are all fine. What actually breaks is a bare value containing a space, and a function name written without its parentheses. The error on that last one is worth reading closely, because it is not a syntax error at all:
The value 'standardIssueTypes' does not exist for the field 'issuetype'.
Cloud parsed it as a literal value rather than a function call. It is not complaining about your grammar, it is telling you there is no issue type by that name. Which implies that if an issue type called standardIssueTypes existed, the query would become valid and mean something completely different. That felt too silly to assert without checking, so I checked:
before issuetype in (standardIssueTypes) NOT QUERYABLE
create issue type "standardIssueTypes" id=10206
after issuetype in (standardIssueTypes) QUERYABLE
delete issue type 10206
after cleanup issuetype in (standardIssueTypes) NOT QUERYABLE
So the paren-less form is not a broken function call. It is a perfectly good value lookup that happens to be looking up a value nobody has created. Nothing turns on that in a real migration, but it tells you the parser is doing far less semantic work than the error messages suggest, which is the theme of everything below.
Where the destination's ids come from
The reason cf[10042] cannot survive a move unless something rewrites it is that the destination has never heard of your numbering.
I created two custom fields, one after the other, on a site whose highest existing custom-field id was 10640:
created: customfield_10924 | LZ Probe Alpha
created: customfield_10925 | LZ Probe Beta
Not 10641. The site allocates from its own counter, which had already run 284 ahead of anything still visible on it. I did not chase down where those numbers went and it does not much matter. What matters is that nothing about the number I got derives from the field's name, its type, or what it was called on the source. Two sites agreeing on an id is coincidence.
Atlassian says the same thing in their own documentation, and this is the sentence to quote at anyone who tells you the ids will line up:
When you copy data for specific projects or spaces, identifiers (such as custom field identifiers like
customfield_10456, board IDs, content page identifiers) are not guaranteed to be the same between your production and sandbox environments.
That is from the what happens when you copy data page, read on 12 August 2026. It is written about sandbox copies, but the allocation mechanism is the same one I measured above, and it does not care why the data arrived.
So: the JQL is literal text, and the number inside it means something local. Put those together and a filter moved between sites is a query that will bind to whatever the destination happens to have at that number. If the slot is empty, you get an error. If the slot is occupied by something else, you get results.
Route one: Data Center to Cloud
On JCMA, which filters come across is a setting in the migration plan. What happens to the ids inside them changed in version 1.12.12.
Atlassian's what gets migrated page lists cross-project filters in its migrated column. Dashboards and filter subscriptions are in that column as well, and so are filters on boards that are not migrated and filters that share permissions with projects. The boards and filters page has the option. "Only filters and boards shared to selected projects" leaves behind single-project filters that are private or shared with users, groups, the public or projects you are not migrating. "All filters and cross-project boards" takes "All filters (both single and cross-project) and cross-project boards, irrespective of their shared access".
The first version of this post said most standalone filters stay behind on JCMA. I misread which column the filters sat in, and the all-filters option already existed when I wrote it. Pick it. If you migrate in waves, expect the Requires-attention report to flag filters whose JQL points at sprints or issues in projects you have not moved yet. The KB files those under "missing from this migration" and says to add the projects to the plan. Anything you still carry across yourself over the API is yours to rewrite.
What JCMA does with the JQL changed in version 1.12.12, released in November 2024. Atlassian's KB on JQL resolution errors says it converts Data Center entity ids in filter JQL to their Cloud ids, that it "only partially sanitises the JQL string", and that ids it cannot resolve go into the Requires-attention report, with the warning "not every thing we can’t resolve is getting reported". Its table lists cf[*] and user clauses among the ids it handles. The product manager's announcement on JRACLOUD-83096 lists eleven fields, with no custom fields and no user fields. I have not run a JCMA migration to settle which is right. So after the migration, run the audit script further down on Cloud and compare, filter by filter, the field name it prints for each cf[N] with the field that clause pointed at on Data Center, because the report cannot vouch for its own gaps.
If you do move filters yourself, the destination pushes back on most of it. POST /rest/api/3/filter validates the JQL to the same standard as the strict parser, and it refuses:
customfield_10924 ~ "alpha" REJECTED Field 'customfield_10924' does not exist...
cf[99999] is not EMPTY REJECTED Field 'cf[99999]' does not exist...
filter = 99999 REJECTED A value with ID '99999' does not exist for the field 'filter'.
You cannot save a filter pointing at a field the destination does not have. Your import will fail on those rows, you will see the failures, and you will go and build a proper id map. That only holds while the slot is empty on Cloud. A Data Center id that happens to match a Cloud field saves fine and reads the wrong field, the same failure as the Cloud to Cloud case below.
Then there is the hole.
assignee = jsmith AND project = WFH SAVED id=10480
jsmith is a Data Center username. It means nothing on Cloud, where users are identified by account id and the name field does not exist. The parser took it, the save endpoint took it, and the filter now sits on the destination matching nobody, forever, with no error anywhere.
I pushed on this to see how far it goes:
assignee = "not-an-account-id-at-all" ACCEPTED
assignee = "557058:00000000-0000-0000-0000-000000000000" ACCEPTED
Compare that against everything else, which is checked properly:
| Clause | Strict validation |
|---|---|
| nonexistent field | rejected |
| nonexistent filter id | rejected |
| nonexistent project key | rejected |
| nonexistent status name | rejected |
| any string at all as a user | accepted |
That asymmetry is the single most useful thing I learned today. Validation is not a safety net for identity. On a DC to Cloud move, every filter you carry across by hand that names a person will pass every check you run and be wrong, and so will any user clause JCMA fails to convert. Grep for assignee, reporter, creator, watcher, voter and the membership functions before you import.
A filter that returns zero rows looks identical to a filter whose criteria genuinely match nothing. This is why the username hole survives so long in the wild. Nobody investigates an empty result, they assume the backlog is clean.
Route two: Cloud to Cloud
On Cloud to Cloud the trouble is the ids inside the filters.
First, the filters do come across. Atlassian's what data is copied page for the org-level data transfer explicitly includes "Filters not linked to any boards" and "Filters linked to more than one project", plus dashboards with their portlet configuration. It offers the same three choices as JCMA, so "All filters and cross-project boards" is again the one that takes everything.
Second, identity ports. Atlassian's user privacy developer guide puts it plainly: an account id uniquely identifies a user across all Atlassian products. It is not scoped to a site, so assignee = "5b10a2844c20165700ede21g" refers to the same human on both ends. That does not guarantee they have product access on the destination, and if they do not the filter returns nothing, but the identifier itself is stable. On this route people are the easy part.
What does not port is every configuration id, and now nothing is stopping the bad query from being saved, because the id resolves. It just resolves to something else.
This is the shape of it, from the audit script I will hand you in a moment, run against a site where I had renamed the underlying field:
[10479] LZ Newsroom Probe Filter
cf[10924] ~ "alpha" ORDER BY created ASC
cf[10924] -> "LZ Probe TOTALLY DIFFERENT"
The filter is called one thing, its JQL searches for "alpha", and the field it actually reads is named something with no relationship to either. No error was raised at any point in producing that. On a real cross-site move, substitute "a text field on the destination that happens to sit at 10924" and you have a filter that runs every morning and reports on the wrong column.
The same applies to filters that reference other filters. filter = 10010 was accepted on my site because a filter with that id exists there. It is a completely different filter from the one that had id 10010 on the source. Boards carry the same problem one level up: GET /rest/agile/1.0/board/11/configuration returns filter.id = 10016 and a column configuration made of raw status ids. Every one of those is a local number.
So the honest summary of the two routes is not that one is safer:
| Data Center to Cloud | Cloud to Cloud | |
|---|---|---|
| Standalone filters | all of them if you pick "All filters", otherwise only those shared with migrating projects | the same three-way choice as JCMA |
| Config ids (cf[N], filter = N) | JCMA 1.12.12+ rewrites the ids Atlassian lists and reports some of what it misses; hand-moved ids fail loudly only if the slot is empty | may exist and mean something else, silent |
| Users in JQL | JCMA's KB says it converts them, its 1.12.12 announcement leaves them out; usernames you carry across by hand are meaningless, silent failure | account ids are global, generally fine |
| Where to spend your time | the Requires-attention report and the audit on the Cloud side, then the id map and usernames for anything moved by hand | proving what each surviving id resolves to |
I could not test Atlassian's native cloud-to-cloud transfer, because that needs two sites and org admin, and I have one throwaway site. Its documentation does not say whether it rewrites custom field ids inside copied filter JQL. Atlassian's tracker does say it leaves project ids alone (CLOUD-12025, accepted as a bug and still in progress), and sprint and team ids have open tickets of their own (CLOUD-12121, CLOUD-12172). I measured that the JQL is stored as literal text and that cf[N] binds to the destination's table, and Atlassian documents the ids as not guaranteed to match. Any route that moves the text without rewriting it produces the failure above. Check your own copy rather than trusting me on which routes do that.
Automation rules split between the two routes too: JCMA brings them across disabled and without their actors, while the cloud-to-cloud transfer marks automation flows with a cross and brings nothing at all. Both roads end in the same Rule Management API, where the actor is validated at create time — and on the site I measured, 11 of 18 existing rules ran as an account that had already been deactivated.
The fix that is not a fix
The obvious move at this point is to stop using ids. Rewrite every cf[10042] to the field's name before you migrate, since names are human and portable.

Measured on one site: most existing rules were already running as a deactivated account, before any migration touched them.
I thought that too, until I looked at what is actually on a real tenant. There were two custom fields called "Team":
customfield_10001 | Team | type atlassian-team
customfield_10395 | Team | type textfield
One is the Atlassian Teams field, the other is somebody's short text field. And this parses without complaint:
Team is not EMPTY ACCEPTED
The parse endpoint returns the structure, and the structure only says the field is called "Team". The first version of this post stopped there and called the ambiguity invisible. I re-tested it on 13 October with throwaway fields, and Jira uses both. With two fields of the same type, a name clause matches either one: = High picks up issues from both fields, and is EMPTY only matches issues where both are empty. With two different types, is not EMPTY still saves, but Team = "x" fails with HTTP 400 and "There are multiple custom fields with the name 'Team'". So rewriting to names swaps an id the audit can check for a name that can match two fields without saying so.
There is a middle option that works, which is the bracketed form Jira itself generates: "Team[Team]" and "Start date[Date]" disambiguate by type. The HTTP 400 above suggests it too, in the part of the message I cut: Try using one of the following terms: "Team[Short text]", "Team[Team]". Real filters on the site already used it. It is still not unique if you have two text fields with the same name, where it matches both, but it collapses most collisions and it is portable in a way a raw number is not.
The actual answer is that you need the mapping either way. Build it from both sides, keep it in a file, and rewrite deliberately. There is no encoding of the query that saves you from having to know what the destination calls things.
Two ways the destination lies to you about its own fields
While building the mapping I hit two things that would have quietly corrupted it.
The first is that there are two endpoints that list custom fields and they do not agree. GET /rest/api/3/field returned 188 custom fields. GET /rest/api/3/field/search?type=custom returned 197. The nine extra are exactly the ones the JQL parser refuses to touch, and they are not exotic: two pairs of app-installed fields with duplicated names, a few leftovers, one locked field owned by another product. If you build your destination-side map from field/search, you will map source fields onto destination fields that cannot be queried, and every filter using them will fail at save time with a message saying the field does not exist, which will send you looking in entirely the wrong place.
Use /rest/api/3/field for anything JQL-related. It is the queryable set, by definition.
The second one cost me twenty minutes and is genuinely useful if you provision fields programmatically. A custom field created over REST gets a global context automatically, and is still invisible to JQL:
customfield_10924 contexts: 1 [('11216', 'Default Configuration Scheme for LZ Probe Alpha', True)]
cf[10924] ~ "alpha" NOT QUERYABLE
I waited, on the theory that it was an indexing delay. It was not. What fixed it was putting the field on a screen. Look the tab id up rather than copying mine, because tab ids are allocated per screen and are not predictable. On my site Default Screen was id 1 with a single tab at 10000, while another screen's only tab was 10007:
# find the screen, then its tab
curl -u "$EMAIL:$TOKEN" "https://$SITE/rest/api/3/screens?maxResults=50"
curl -u "$EMAIL:$TOKEN" "https://$SITE/rest/api/3/screens/1/tabs"
# then add the field to that tab
curl -u "$EMAIL:$TOKEN" -X POST \
-H "Content-Type: application/json" \
"https://$SITE/rest/api/3/screens/1/tabs/10000/fields" \
-d '{"fieldId":"customfield_10924"}'
Thirty seconds later it was queryable and it had appeared in /rest/api/3/field. The control matters here, so I will give it: customfield_10925 was created in the same second as 10924, by the same call pattern, and was never added to a screen. It stayed unqueryable throughout. One variable, opposite outcomes.
So a provisioning script that creates destination fields and then writes filters against them will fail, and the failure message will tell you the field does not exist when it demonstrably does. Add the screen association before the filter import, not after.
An audit you can run
This is the script I actually ran. It is read-only. It walks every saved filter on a site, pulls the id tokens out of the JQL, and tells you what each one resolves to there. It only sees filters its token can see, so other people's private filters are skipped. If the token has Administer Jira, add &overrideSharePermissions=true to the filter/search call. On my site in October that was 19 filters out of 139.
It targets Cloud, on the v3 API, and I have only run it against Cloud. On a Cloud to Cloud move you can point it at both ends and diff the output directly. Coming from Data Center you will need to change the two endpoints, because the paths and the pagination differ there, so treat the Cloud run as the destination half and pull the source list however your Data Center version prefers.
#!/usr/bin/env node
// filter-audit.js - dump every saved filter's JQL and resolve the id tokens
// it carries against THIS site's field table. Read-only.
//
// SITE=your-site.atlassian.net EMAIL=you@example.com TOKEN=xxx node filter-audit.js
const { SITE, EMAIL, TOKEN } = process.env;
const auth = "Basic " + Buffer.from(`${EMAIL}:${TOKEN}`).toString("base64");
async function api(path) {
const res = await fetch(`https://${SITE}${path}`, {
headers: { Authorization: auth, Accept: "application/json" },
});
if (!res.ok) throw new Error(`${res.status} ${path} ${await res.text()}`);
return res.json();
}
async function allFilters() {
const out = [];
for (let startAt = 0; ; ) {
const page = await api(
`/rest/api/3/filter/search?expand=jql&maxResults=50&startAt=${startAt}`
);
out.push(...page.values);
if (page.isLast) break;
startAt += page.values.length;
}
return out;
}
(async () => {
// The JQL-usable field set. NOT /field/search - that one lists fields the
// parser will refuse.
const fields = await api("/rest/api/3/field");
const byId = new Map(
fields
.filter((f) => f.custom && f.schema?.customId != null)
.map((f) => [String(f.schema.customId), f.name])
);
const nameCount = new Map();
for (const n of byId.values()) nameCount.set(n, (nameCount.get(n) || 0) + 1);
const filters = await allFilters();
console.log(`site=${SITE} filters=${filters.length} jql-usable custom fields=${byId.size}`);
for (const f of filters) {
const jql = f.jql || "";
const cfIds = [...jql.matchAll(/\bcf\[(\d+)\]/g)].map((m) => m[1]);
// Two shapes, kept separate on purpose: a bare `filter = N` operand, and a
// parenthesised IN-list. One combined regex over-matches into the next clause.
const filterIds = [
...[...jql.matchAll(/\b(?:filter|savedFilter)\s*(?:=|!=)\s*"?(\d+)"?/gi)].map(
(m) => m[1]
),
...[...jql.matchAll(/\b(?:filter|savedFilter)\s+(?:not\s+in|in)\s*\(([^)]*)\)/gi)]
.flatMap((m) => [...m[1].matchAll(/\d+/g)].map((x) => x[0])),
];
const legacy = [...jql.matchAll(/\bcustomfield_(\d+)\b/g)].map((m) => m[1]);
if (!cfIds.length && !filterIds.length && !legacy.length) continue;
console.log(`\n[${f.id}] ${f.name}`);
console.log(` ${jql}`);
for (const id of new Set(cfIds)) {
const name = byId.get(id);
const dup = name && nameCount.get(name) > 1 ? " <-- NAME IS AMBIGUOUS HERE" : "";
console.log(
name
? ` cf[${id}] -> "${name}"${dup}`
: ` cf[${id}] -> UNRESOLVED on this site`
);
}
for (const id of new Set(filterIds)) {
let label = "UNRESOLVED on this site";
try {
label = `"${(await api(`/rest/api/3/filter/${id}`)).name}"`;
} catch {}
console.log(` filter ${id} -> ${label}`);
}
for (const id of new Set(legacy))
console.log(` customfield_${id} -> NOT A VALID JQL TOKEN (rewrite to cf[${id}])`);
}
})();
Output against my site while the probes were still in place, which is why the field count reads one higher than the 188 elsewhere in this piece and the filter count reads 21 rather than 18:
site=wolfaenpak.atlassian.net filters=21 jql-usable custom fields=189
[10481] LZ Probe cross-ref
filter = 10010 AND cf[10924] ~ "alpha"
cf[10924] -> "LZ Probe TOTALLY DIFFERENT"
filter 10010 -> "10d to Due date"
[10482] LZ Probe in-list
filter in (10010, 10009) AND cf[10924] ~ "alpha"
cf[10924] -> "LZ Probe TOTALLY DIFFERENT"
filter 10010 -> "10d to Due date"
filter 10009 -> "Expired due dates"
One note on that regex, since it bit me while writing this. My first version used a single pattern for both the filter = N and filter in (...) forms, with [^)]* for the operand. On filter = 10010 AND cf[10924] ~ "alpha" it ran straight past the operand and reported a phantom filter 10924. Splitting it into two patterns fixed it. If you adapt this, test it against a query that has a cf[...] clause after the filter clause, because that is the case that breaks.
The script does not tell you the answer. It tells you what each site thinks the query means, and the diff between those two answers is your actual migration work.
What I would do
Capture the source's filter definitions first, while you still have the source, and keep the output somewhere outside both systems. It is the only record of what those queries were supposed to mean, and once the source is decommissioned it is gone.
Then, depending on the route. Coming from Data Center, run JCMA 1.12.12 or later with "All filters and cross-project boards" picked and work through the JQL resolution entries in the Requires-attention report. After that, run the audit on the Cloud side and check, filter by filter, that each cf[N] resolves to the field it pointed at on Data Center, allowing for the "(migrated)" suffix JCMA adds when the name already exists on Cloud. For anything you move yourself, build the field id map from the field list on both ends, /rest/api/2/field on Data Center and /rest/api/3/field on Cloud, rewrite cf[N] through it, and separately grep every filter for the user clauses and rewrite usernames to account ids. The id work fails loudly when the destination slot is empty. The user work never does, so do it deliberately rather than trusting the import to complain.
Coming from another Cloud site, assume every surviving id is guilty. Run the audit on both sites, diff the resolved names, and treat any filter where the source and destination disagree about what cf[N] is called as broken even though it runs. Leave the account ids alone, they are the one thing that ports.
Both routes, before you hand anything back to users: pick the ten filters that feed dashboards or automation rules, open them, and compare the row count against what the source used to return. Not the syntax, the count. Syntax is what the parser already checked for you, and the parser is not the thing that got this wrong. If you script that count, check each query with POST /rest/api/3/jql/parse?validation=strict first. On 13 October /rest/api/3/search/jql answered 200 with zero issues and no warning for cf[99999] = 1 and for nosuchfield = x, over GET and over POST, while strict parse rejected both. It also ran a two-type = High clause that the filter endpoint refuses to save.
The question I have not answered, and would like to: does anyone know whether Atlassian's cloud-to-cloud transfer rewrites custom field ids inside copied filter JQL? The tracker has tickets for project, sprint and team ids. The documentation still says nothing about cf[N], and I could not test it with one site.
Where this was measured, and what I did not check
Everything numbered above was run on 12 August 2026 against a throwaway Jira Cloud site on the Free plan, with a site-admin API token, using the v3 REST API, except the duplicate-name and /rest/api/3/search/jql re-tests, which ran on 13 October 2026 on the same site, by then on a paid plan. The site had 188 JQL-usable custom fields and 18 saved filters my admin token could see before I started in August, and the same 188 and 18 when I finished, because the probe fields and filters were deleted afterwards. The October probes were deleted too.
The mechanisms are not plan-specific as far as I can tell. I measured the August results on Free and the October re-tests on a paid plan, so if you are on Premium or Enterprise treat the numbers as illustrative and re-run the audit rather than quoting mine. I tested Jira only. Confluence has the same class of problem through CQL and content ids, and I have not measured that one. And as said above, I did not run a real two-site transfer, so the cross-site collision is demonstrated by mechanism rather than by a live cross-site reproduction. I have not run a JCMA migration either. I took its JQL handling from Atlassian's KB and tracker, checked on 13 October 2026.
The same split shows up in attachments, and there the silent failure is worse: Jira Cloud answers an attachment download with a 303 and a naive follower writes zero bytes, which the destination then accepts with an HTTP 200.
Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.
Top comments (0)