<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mihai Perdum</title>
    <description>The latest articles on DEV Community by Mihai Perdum (@mihai_leanzero).</description>
    <link>https://dev.to/mihai_leanzero</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4130453%2Fdb62e2a0-c593-4f6c-a75e-49ca13efbeb5.jpeg</url>
      <title>DEV Community: Mihai Perdum</title>
      <link>https://dev.to/mihai_leanzero</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mihai_leanzero"/>
    <language>en</language>
    <item>
      <title>Atlassian Team '26 Europe announcements: AMP, MCP</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Thu, 08 Oct 2026 06:00:08 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/atlassian-team-26-europe-announcements-amp-mcp-1ek8</link>
      <guid>https://dev.to/mihai_leanzero/atlassian-team-26-europe-announcements-amp-mcp-1ek8</guid>
      <description>&lt;p&gt;The Atlassian Team '26 Europe announcements came out on Wednesday 7 October, the day of the Founder Keynote in Amsterdam, and they fill 11 rows on our status board. Three of them get the most space here: AMP (the Agentic Multiplayer Protocol), the Atlassian MCP Server and Rovo Work, a new mode in Rovo Chat. The morning after, our test organization had no Rovo Work. Its MCP server page did list 19 toolsets tagged New, and every new read, write and search one was already allowed.&lt;/p&gt;

&lt;p&gt;Atlassian's &lt;a href="https://www.atlassian.com/blog/company-news/team26-europe-ai-platform" rel="noopener noreferrer"&gt;platform post&lt;/a&gt; says AMP is "live across the Atlassian platform today", and the features under it carry their own labels, from generally available to beta, early access and Soon. What reaches you as an admin is mostly settings and Rovo credits.&lt;/p&gt;

&lt;p&gt;Sami Shaik hit the same default and posted about it two days before the keynote. He found "a new write toolset switched on because 'allow new write toolsets by default' was on" (&lt;a href="https://community.atlassian.com/forums/discussion/3310181/four-switches-govern-external-ai-access-to-your-data-three-of-them-do-not-talk-to-each-other" rel="noopener noreferrer"&gt;Four switches govern external AI access to your data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Most of the announcements are still coming, but the switches are already here. I'd spend this week on the switches.&lt;/p&gt;

&lt;p&gt;This is part 4 of our series. &lt;a href="https://leanzero.net/blog/atlassian-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Part 1 is the hub&lt;/a&gt; with the event times, and &lt;a href="https://leanzero.net/blog/team-26-europe-amsterdam-jira-sentiment-analysis?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;part 3 covered Tuesday&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fd798980d4a1d881449d5e541741b48edc28f3598-1600x1098.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fd798980d4a1d881449d5e541741b48edc28f3598-1600x1098.png" title="Status words quoted from Atlassian's posts and docs, next to what our test organization showed on 8 October. Your org may differ." alt="A status board listing 11 Team '26 Europe announcements, the status words Atlassian's posts and docs use for each, and what our test organization showed on 8 October" width="800" height="549"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things we found, mostly on our test org
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Of the 11 items on our board, only the Atlassian MCP Server carries a GA label, and its v2 has been GA since 8 September.&lt;/li&gt;
&lt;li&gt;Our MCP Permissions tab listed 36 toolsets, 19 tagged New. Every read, write and search one was allowed, new ones included, and Delete and Manage were blocked.&lt;/li&gt;
&lt;li&gt;Next door, the Teamwork Graph CLI allowed all 32 of its permissions, the 7 delete ones included.&lt;/li&gt;
&lt;li&gt;Rovo Work wasn't in any Rovo Chat menu on our test site on 8 October, with beta features switched on.&lt;/li&gt;
&lt;li&gt;Code context turns itself on for Rovo-connected Bitbucket workspaces, and it keeps indexed code in the United States.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  AMP: the Agentic Multiplayer Protocol
&lt;/h2&gt;

&lt;p&gt;Atlassian calls &lt;a href="https://www.atlassian.com/platform/agentic-multiplayer-protocol" rel="noopener noreferrer"&gt;AMP&lt;/a&gt; "the collection of human-centric experiences, technologies, and patterns that shape how humans and agents work together". In practice, agents show up next to people: the &lt;a href="https://www.financialcontent.com/article/bizwire-2026-10-7-atlassian-introduces-amp-the-agentic-multiplayer-protocol-to-power-human-ai-collaboration" rel="noopener noreferrer"&gt;press release&lt;/a&gt; says they "now appear in real-time presence bars and cursors alongside human teammates on canvases and whiteboards". The AMP page also describes registering an agent once and choosing where it shows up, on a Jira board, a Confluence page or in Rovo Chat, but gives no date for it. Mike Cannon-Brookes's &lt;a href="https://www.atlassian.com/blog/company-news/team26-europe-founder-update" rel="noopener noreferrer"&gt;founder update&lt;/a&gt; says "AMP begins rollout today", and no Atlassian page we read gives it a plan tier. You won't find a setting called AMP.&lt;/p&gt;

&lt;p&gt;What you'll actually configure is identity. The press release says agents can "Run as User" or use dedicated service accounts. The closest settings that exist today are Rovo Studio's "User's account" and "Agent's account", though Atlassian doesn't say they're the same thing. For automation, its doc &lt;a href="https://support.atlassian.com/studio/docs/understand-rovo-agent-accounts/" rel="noopener noreferrer"&gt;suggests the Agent's account&lt;/a&gt; "to minimize security risks". I compared Rovo agent permissions with the approval step in our own app, CogniRunner, &lt;a href="https://leanzero.net/blog/rovo-agent-permissions-cognirunner-approvals?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;in an earlier post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atlassian MCP Server (the Rovo MCP server): from dozens of tools to 200+
&lt;/h2&gt;

&lt;p&gt;It's the only GA row on our board. &lt;a href="https://www.atlassian.com/blog/company-news/team26-europe-atlassian-mcp" rel="noopener noreferrer"&gt;Atlassian's MCP post&lt;/a&gt; says "Atlassian MCP is generally available now" with "220+ tools" (its platform post says 200+), and the press release says it uses up to 25% fewer tokens, "in internal benchmarking on Claude models". The &lt;a href="https://developer.atlassian.com/changelog/#CHANGE-3431" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; shows v2 reached GA on 8 September, so if you read the developer changelog, you've met it already. Your admin page still calls it the Rovo MCP server.&lt;/p&gt;

&lt;p&gt;Go to Atlassian Administration, then Rovo, then Rovo MCP server, then Permissions. Ours said "New toolsets available". Read was 15 of 15 allowed, Write 15 of 15, Search 4 of 4. Eight write toolsets were tagged New, among them write_goals, write_projects and write_teams. We don't know when each tag appeared: Atlassian's changelog added most of these families in September. The &lt;a href="https://support.atlassian.com/security-and-access-policies/docs/configure-atlassian-mcp-server-permissions/" rel="noopener noreferrer"&gt;permissions doc&lt;/a&gt; explains the rest: "New permissions won't require manual review by admins".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F24e068b786e1e042fd54099d66764910b014797f-900x1286.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F24e068b786e1e042fd54099d66764910b014797f-900x1286.png" title="Our test org, 8 October. Look for the purple New tags. With Allow all toolsets selected, each one was allowed without a separate approval." alt="The Write permission panel of the Atlassian Rovo MCP server in Atlassian Administration, set to Allow all toolsets, with write_assets, write_capacity_planning and write_focus tagged New" width="800" height="1143"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read toolsets arriving on their own worry me less, since every action &lt;a href="https://support.atlassian.com/atlassian-ai-gateway/docs/get-started-with-the-atlassian-remote-mcp-server/" rel="noopener noreferrer"&gt;respects the user's existing permissions&lt;/a&gt;. Still, the new ones on ours included read_loom and read_talent, which reach Loom meeting recordings and Talent headcount metrics, so look through yours. Write is different, and I'd want to see each one first. That takes two steps. While Write says Allow all toolsets, its switches are greyed out. Change it to Allow some toolsets and you can set each one yourself, and Allow new write toolsets turns off with it. All 15 stay allowed until you clear them.&lt;/p&gt;

&lt;p&gt;Delete and Manage were 0 of 1 on our org, with their allow-new switches off. Good, because delete_jira can "permanently delete issues, comments, and attachments".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F93aabbdb2e1fa4c6e7c87380805b97de2258f042-1600x1240.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F93aabbdb2e1fa4c6e7c87380805b97de2258f042-1600x1240.png" title="Delete and Manage stay blocked, and new delete toolsets don't arrive on their own. Check that yours say the same." alt="The Atlassian Rovo MCP server Permissions tab with Read 15/15, Write 15/15 and Search 4/4 allowed, Delete and Manage 0/1, and the Delete panel showing delete_jira blocked" width="800" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Domain allowlists and the MCP control in data security policies only apply to OAuth connections. An AI tool using an API token skips the domain list, and so does one connected through enterprise managed authentication, which is in beta. Both were off on our org, so check yours.&lt;/p&gt;

&lt;p&gt;Atlassian also adds tools inside toolsets: on 30 September, getJiraScreen and updateJiraScreen went into manage_jira, available to users who had already approved it. And on 1 March 2027, anything still on v1 starts using the v2 tools automatically. Every tool call lands in the audit log on all tiers (&lt;a href="https://support.atlassian.com/security-and-access-policies/docs/monitor-atlassian-mcp-server-activity/" rel="noopener noreferrer"&gt;monitor MCP activity&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Ffdda947760d997f3df0368315e3ef0388609a9af-1600x896.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Ffdda947760d997f3df0368315e3ef0388609a9af-1600x896.png" title="Illustration, generated locally with FLUX. On our org, read and write toolsets walked straight past and delete_jira waited at the rope. Somebody on your team gets to be the bouncer." alt="Cartoon of a big blue boxy robot bouncer with one hand raised, pointing at a line of small yellow robots with folders in the doorway of a server room, while a worried pink eraser-shaped robot holds the far end of a red velvet rope" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Teamwork Graph CLI sits two items below the MCP server in the Rovo menu, and it surprised me more. On our org, "Allow all permissions by default" was on. View was 13 of 13, and Write and manage was 12 of 12. Delete was 7 of 7.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F634a144290981b485dc7510439f38c74c6510288-1400x822.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F634a144290981b485dc7510439f38c74c6510288-1400x822.png" title="Our test org, 8 October. Delete 7 of 7 allowed, and new permissions allowed automatically." alt="The Teamwork Graph CLI page in Atlassian Administration with Allow all permissions by default switched on and View 13/13, Write and manage 12/12 and Delete 7/7 allowed" width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rovo Work: announced, but not in Rovo Chat on our site yet
&lt;/h2&gt;

&lt;p&gt;The platform post says Work "handles complex, multi-step tasks", that "A task can run for hours", and that it runs "in a secure sandbox your admins govern". Atlassian gives it no status, and we couldn't find a doc page. On our test site, with beta features switched on for the org, the reasoning menu offered Let Rovo decide, Quick answers, Think deeper and Deep Research. The + menu had skills, files, links, mentions and formatting. No Work.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F3bd7015e3416fd14c1ac06d5cf584a6b72cb7d06-715x645.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F3bd7015e3416fd14c1ac06d5cf584a6b72cb7d06-715x645.png" title="Our test site, 8 October. Four reasoning options, and Work isn't one of them yet." alt="The Rovo Chat reasoning menu on our test site, showing Let Rovo decide, Quick answers, Think deeper and Deep Research" width="715" height="645"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keep an eye on cost. The &lt;a href="https://www.atlassian.com/licensing/rovo" rel="noopener noreferrer"&gt;licensing FAQ&lt;/a&gt; lists "Work mode in Rovo Chat" under premium AI interactions with variable pricing. Extra usage billing for Rovo credits starts on 3 December 2026 at a list price of $0.01 a credit, and Atlassian's doc says extra usage "is enabled by default" (&lt;a href="https://support.atlassian.com/rovo/docs/rovo-usage-limits/" rel="noopener noreferrer"&gt;How Rovo credits work&lt;/a&gt;). On our test org it wasn't on yet: the Rovo credits page offered Enable extra usage. I want to try Work. I just can't tell you yet what one long task costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent accounts and a non-human identity inventory: Soon
&lt;/h2&gt;

&lt;p&gt;The platform post says "Soon" for "agent accounts and non-human identity inventory": one place to see every agent, app and service account, and switch any of them off. Today our Directory has Users, Groups, Teams, Managed accounts, Service accounts and Domains. Rovo agents sit under Rovo, then Agents. The inventory isn't there yet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fe8610be1a03ae3d838632e55c92469ac483421a3-1600x896.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fe8610be1a03ae3d838632e55c92469ac483421a3-1600x896.png" title="Illustration, generated locally with FLUX. Atlassian says agent accounts and an identity inventory are coming Soon. This is one desk of several. Until the inventory arrives, agents, apps and service accounts each get their badge in a different place." alt="Cartoon of a crowd of colourful boxy robots at a reception desk, where a smiling robot in a suit, wearing its own lanyard, hands a strap to the nearest one" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Local EU AI inference: rolling out, with no setting we could find
&lt;/h2&gt;

&lt;p&gt;Atlassian says it restricts "LLM processing exclusively to models hosted within the EU", and the founder update says it's "rolling out". We couldn't find a doc page. Our Data residency page has no inference setting either. Data residency pins where data is stored. This one is about where the model runs, so I'd ask your account team before telling your DPO you're covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code context: open beta, code kept in the US
&lt;/h2&gt;

&lt;p&gt;There are three names here: "Rovo Code Search" in the platform post, "Code Search app" in the press release, and code context in the docs. Atlassian doesn't say they're the same. I won't guess. Code context is &lt;a href="https://www.atlassian.com/blog/company-news/team26-europe-ai-sdlc" rel="noopener noreferrer"&gt;"gradually rolling out ... through open beta"&lt;/a&gt; on Standard, Premium and Enterprise. Indexed code is "stored and processed in the United States", and data residency isn't supported (&lt;a href="https://support.atlassian.com/rovo/docs/set-up-code-context-for-your-site/" rel="noopener noreferrer"&gt;set up code context&lt;/a&gt;). For a paid Bitbucket workspace connected to a Jira site with Rovo, "Atlassian will automatically enable Code context". Our admin page puts it as "by January 2027".&lt;/p&gt;

&lt;h2&gt;
  
  
  The Artifacts app: in beta, private by default
&lt;/h2&gt;

&lt;p&gt;AI-made plans and HTML views get permanent links you can embed in Confluence or Jira. The &lt;a href="https://support.atlassian.com/platform-experiences/docs/what-is-atlassian-artifacts/" rel="noopener noreferrer"&gt;doc&lt;/a&gt; says "Atlassian Artifacts is in beta. It's available to all paid plans." New artifacts are private. A deleted one "can't be restored", so plan for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atlassian Insights: a phased rollout
&lt;/h2&gt;

&lt;p&gt;Structured data from your lakes and warehouses becomes Teamwork Graph context. The &lt;a href="https://www.atlassian.com/blog/company-news/introducing-insights-app" rel="noopener noreferrer"&gt;Insights post&lt;/a&gt; promises "a phased rollout starting after Team '26 Europe" for all paid Cloud customers. If your org uses Atlassian Analytics today, it's been renamed Insights, and your org admin chooses between the existing experience and Rovo Insights.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent Sessions and Interactive PR Reviews: closed EAP
&lt;/h2&gt;

&lt;p&gt;Agent sessions link local and cloud agent runs back to work items. If you use Rovo agents, the Jira coding agent or a cloud coding agent like Claude, Cursor or GitHub Copilot, Jira can already link their sessions to work items (&lt;a href="https://support.atlassian.com/jira-software-cloud/docs/use-the-agents-dashboard/" rel="noopener noreferrer"&gt;Use the Agents dashboard&lt;/a&gt;). Local sessions from IDEs and terminals are the new part, and Atlassian's SDLC post says "Agent Sessions and Interactive PR Reviews in Loom are in closed EAP". Record for Agent, where a quick screen-and-voice recording becomes a brief an agent can turn into Jira work items, is in open beta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atlassian Guard Premium: scanning you can use now
&lt;/h2&gt;

&lt;p&gt;The platform post lists full-site historical scanning, scanning of content and attachments, and guardrails across Rovo Chat. If you have Guard Premium, both scans are documented already, one since April and the other since 2 September. The MCP control in data security policies was announced in the changelog on 29 September and needs Guard Standard (&lt;a href="https://leanzero.net/blog/atlassian-data-security-policy-guard?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;our earlier explainer&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  New connectors: Zoom, Gong, Microsoft Entra ID and Google Identity
&lt;/h2&gt;

&lt;p&gt;Atlassian names these among the additions to its 80+ connectors, which its docs call Teamwork Graph connectors. Zoom has a &lt;a href="https://support.atlassian.com/organization-administration/docs/connect-zoom-to-teamwork-graph/" rel="noopener noreferrer"&gt;setup doc&lt;/a&gt;. If you're after Gong, Entra ID or Google Identity, we couldn't find a doc under those names yet. The nearest Google one is &lt;a href="https://support.atlassian.com/organization-administration/docs/connect-google-workspace-directory-to-teamwork-graph/" rel="noopener noreferrer"&gt;Connect Google Workspace Directory to Teamwork Graph&lt;/a&gt;, published 6 October, and Atlassian doesn't say it's the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also announced: an OpenAI partnership, Flexera in Assets and more
&lt;/h2&gt;

&lt;p&gt;Atlassian posted an &lt;a href="https://www.atlassian.com/blog/company-news/atlassian-openai-strategic-partnership" rel="noopener noreferrer"&gt;OpenAI partnership&lt;/a&gt; on 6 October. On 7 October came a &lt;a href="https://www.atlassian.com/blog/company-news/team26-europe-flexera" rel="noopener noreferrer"&gt;Flexera agreement&lt;/a&gt; bringing its Technopedia data into Jira Service Management and Assets. Three smaller items that aren't on our board carry a GA label: Workforce Management and AI Change Risk Assessment workflows in Jira Service Management, per the &lt;a href="https://www.atlassian.com/blog/company-news/team26-europe-service-collection" rel="noopener noreferrer"&gt;Service Collection post&lt;/a&gt;, and the DX AI Measurement solution, per the SDLC post. Atlassian also profiled its &lt;a href="https://www.atlassian.com/blog/how-we-build/meet-the-forward-deployed-engineer" rel="noopener noreferrer"&gt;Forward Deployed Engineer programme&lt;/a&gt;, which started as a pilot late last year and is open to a select group of enterprise Cloud customers using Rovo.&lt;/p&gt;

&lt;p&gt;For Forge builders, the MCP post says developers "can build custom MCP tools with Forge". The rovo:mcp module does that. It has been in Preview since 14 August. On 1 October the changelog moved one more part into Preview: connecting its tools to external AI clients like Claude Desktop, Codex and Cursor. The module reference still labels that part EAP. The SDLC post says Cognition's Devin, Factory and Warp "are all available on the Atlassian Marketplace today, with OpenAI Codex coming soon".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do this week
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open Rovo, then Rovo MCP server, then Permissions, then Write. Change Allow all toolsets to Allow some toolsets, clear any toolset you don't want, starting with the ones tagged New, and save. That also turns off Allow new write toolsets.&lt;/li&gt;
&lt;li&gt;Keep Delete and Manage blocked unless someone asks with a reason. On the Authentication tab, make sure Allow API token authentication and Allow enterprise managed authentication are off unless something needs them.&lt;/li&gt;
&lt;li&gt;Open Rovo, then Teamwork Graph CLI. If nobody needs it to delete, turn off "Allow all permissions by default", clear Delete, and choose Save and revoke sessions.&lt;/li&gt;
&lt;li&gt;Open Insights, then Platform usage, then Rovo credits, before 3 December. If you see Update extra usage limit, set one. If Get more usage offers Enable extra usage, it isn't on for your org yet, so look again in December.&lt;/li&gt;
&lt;li&gt;If you have a paid Bitbucket workspace connected to a Jira site with Rovo, open Rovo, then Code context. There's no switch to keep it off: the page only offers Request indexing. If your code mustn't be stored in the US, ask your account team now whether you can opt out, or be ready to disable it the day it turns on, which deletes the stored index.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What we're watching next
&lt;/h2&gt;

&lt;p&gt;The Closing Keynote is scheduled for Thursday 8 October at 15:00 CEST, and Atlassian's on-demand library says recordings start on 12 October. Part 3 asked where Rovo agents land in Jira Service Management. The &lt;a href="https://www.atlassian.com/blog/company-news/team26-europe-service-collection" rel="noopener noreferrer"&gt;Service Collection post&lt;/a&gt; puts its AI agents in Slack, Teams and Claude through "service and operations context where you work", which is in closed EAP, and Atlassian's MCP post still lists Jira Service Management workflows as "coming soon". One update we've made to our hub: Atlassian's keynote session page now lists four speakers, not the five it listed on 5 October, and has it ending at 10:30. Its agenda still says 10:15. When Rovo Work or the identity inventory shows up on our org, it'll get its own write-up.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/blog/atlassian-team-26-europe-announcements?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>atlassian</category>
      <category>rovo</category>
      <category>mcp</category>
      <category>events</category>
    </item>
    <item>
      <title>Sentiment analysis in Jira Service Management</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Wed, 07 Oct 2026 05:00:46 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/sentiment-analysis-in-jira-service-management-7g1</link>
      <guid>https://dev.to/mihai_leanzero/sentiment-analysis-in-jira-service-management-7g1</guid>
      <description>&lt;p&gt;I went to Rob Hean's workshop on Jira Service Management queues at Team '26 Europe, and it was excellent, sentiment analysis included. That's the AI field that marks a request Positive, Neutral or Negative. So on 7 October we ran the sentiment part through Jira's strict JQL validator on our two test sites. &lt;code&gt;Sentiment = Negative&lt;/code&gt; is valid, &lt;code&gt;Sentiment = Angry&lt;/code&gt; is rejected, and neither site had a single work item with a sentiment value.&lt;/p&gt;

&lt;p&gt;The only Atlassian text we found that says you can build a queue on sentiment is a 2024 Community post, written when the feature was in beta. The current doc doesn't mention JQL at all. And Atlassian's own example for keeping queues fast, &lt;code&gt;updateDate &amp;lt; 14d&lt;/code&gt;, fails Jira's validation outright.&lt;/p&gt;

&lt;p&gt;The clause is valid. What I can't tell you is whether a queue built on it ever fills. That depends on your plan, the per-space toggle and what your customers write.&lt;/p&gt;

&lt;p&gt;The rest of Tuesday 6 October is below too: the Atlassian Williams F1 show car, a smoothie bike, the partners we met, a short Forge section and what we planned to watch in the Founder Keynote on Wednesday 7 October. This is part 3 of our series. &lt;a href="https://leanzero.net/blog/atlassian-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Part 1 is the hub&lt;/a&gt; with dates and livestream times, and &lt;a href="https://leanzero.net/blog/atlassian-partner-accelerate-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;part 2 covered Partner Accelerate&lt;/a&gt; on Monday. The keynote announcements get part 4, on 8 October.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things we found that you can act on
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Don't copy Atlassian's &lt;code&gt;updateDate &amp;lt; 14d&lt;/code&gt; queue tip. It fails validation. Use &lt;code&gt;updated &amp;gt;= -14d&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Split a slow, complex queue into several simple ones. There's room: the cap is 300 queues per service space per work category.&lt;/li&gt;
&lt;li&gt;On Premium or Enterprise with AI on, turn on Customer sentiment analysis per space. Old work items stay blank until the reporter comments again.&lt;/li&gt;
&lt;li&gt;Build one &lt;code&gt;Sentiment = Negative&lt;/code&gt; queue, put it in a priority group so it refreshes on the regular sync, and count what it catches after a week.&lt;/li&gt;
&lt;li&gt;If your Forge app adds JQL clauses, time them in a queue with real volume. Atlassian tells admins to reduce Marketplace JQL when a queue underperforms.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Configure queues for agent triage in Jira Service Management
&lt;/h2&gt;

&lt;p&gt;Rob Hean runs &lt;a href="https://hean.tech/" rel="noopener noreferrer"&gt;Hean Tech&lt;/a&gt;, which teaches Jira, Jira Service Management, Rovo and Confluence, and a &lt;a href="https://www.youtube.com/@Hean-Tech" rel="noopener noreferrer"&gt;YouTube channel&lt;/a&gt; of the same name. Atlassian lists him as an Atlassian Community Champion. He also has a six-minute explainer from March 2025, &lt;a href="https://www.youtube.com/watch?v=yRiI1o5-G-k" rel="noopener noreferrer"&gt;Jira Service Management Queues | Explained in 6 Minutes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;At Team I sat in on his &lt;a href="https://events.atlassian.com/teameurope/session/4380198/configure-queues-for-agent-triage" rel="noopener noreferrer"&gt;Configure queues for agent triage&lt;/a&gt;, Tuesday 11:00 to 12:00 on Stage 2, Hall 2. It's a Learning session, listed as "an instructor-led hands-on workshop - Laptop required". Sentiment came up too, though it isn't in the published description.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F9892fe6c16995ea26218bdee245c561ae5ea1962-1600x759.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F9892fe6c16995ea26218bdee245c561ae5ea1962-1600x759.jpg" title="Rob Hean's workshop on Tuesday, from the back rows. Look at the tables: it was a laptops-out session, and the three agenda items are on the slide." alt="A breakout hall seen from the back rows: a large slide reading Configure queues for agent triage with the agenda 01 The queue role in triage, 02 Build queues, 03 Organize and manage queues, a speaker on a blue-curtained stage, and attendees at long tables with laptops" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you missed it, it runs again on Thursday 8 October at 10:00 on Stage 3, Hall 2. Otherwise, this is the part of Atlassian's docs I'd read first.&lt;/p&gt;

&lt;p&gt;Queues are filters. Atlassian calls them "a kind of filter for your requests", by type, status or a JQL statement, and you need to be a space admin to &lt;a href="https://support.atlassian.com/jira-service-management-cloud/docs/create-a-new-queue/" rel="noopener noreferrer"&gt;create a new queue&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There's a hard cap: "The total number of queues is limited to 300 per service space per work category across all sections." And a queue's count refreshes up to 999 work items, then just shows 999+ (&lt;a href="https://support.atlassian.com/jira-service-management-cloud/docs/what-are-queues/" rel="noopener noreferrer"&gt;What are queues?&lt;/a&gt;, updated 29 May 2026).&lt;/p&gt;

&lt;p&gt;What refreshes depends on where the queue sits. Queues in Starred and in your &lt;a href="https://support.atlassian.com/jira-service-management-cloud/docs/prioritize-your-queues-by-using-groups/" rel="noopener noreferrer"&gt;priority groups&lt;/a&gt; refresh on a regular sync. Queues under View all queues don't. One queue can sit in several groups. Starring only changes your own view: starred queues "appear only in your view and won't impact your team's work".&lt;/p&gt;

&lt;p&gt;Rovo can also write the JQL. In Rovo Chat, &lt;code&gt;/manage-queue&lt;/code&gt; generates the filter and the column layout. It can't delete queues or queue groups.&lt;/p&gt;

&lt;h3&gt;
  
  
  Atlassian's own date example doesn't validate
&lt;/h3&gt;

&lt;p&gt;Atlassian's &lt;a href="https://support.atlassian.com/jira-service-management-cloud/docs/best-practices-for-managing-queues-at-scale/" rel="noopener noreferrer"&gt;best practices for managing queues at scale&lt;/a&gt; is blunt, and I agree with most of it. "Often it is better to have multiple simple queues than one complex one." Text search is "the worst of all". And it says "JQL clauses provided by Marketplace apps installed on your site can sometimes be problematic. Reduce their use if a queue is underperforming." That applies to app vendors, us included.&lt;/p&gt;

&lt;p&gt;Its tip to "Show only recent work items, e.g. use updateDate &amp;lt; 14d in your JQL" is the one I'd fix. Don't copy it. On our wolfaenpak test site, strict validation answers "Field 'updateDate' does not exist or you do not have permission to view it." The field is &lt;code&gt;updated&lt;/code&gt;, alias &lt;code&gt;updatedDate&lt;/code&gt;. Spell it &lt;code&gt;updatedDate &amp;lt; 14d&lt;/code&gt; and it validates, but it matched all 23,280 work items, because &lt;code&gt;14d&lt;/code&gt; without a minus sign means 14 days in the future. On 7 October, &lt;code&gt;updated &amp;gt;= -14d&lt;/code&gt; matched 4,693. That's the form I'd put in a queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting Jira customer sentiment into a queue with JQL
&lt;/h2&gt;

&lt;p&gt;Atlassian's doc, &lt;a href="https://support.atlassian.com/jira-service-management-cloud/docs/about-customer-sentiment-analysis/" rel="noopener noreferrer"&gt;View customer sentiment on work items&lt;/a&gt; (updated 29 July 2026), says the feature "uses AI to determine how customers are feeling based on the content of their request and their recent comments". The result is Positive, Neutral or Negative, shown in the Sentiment field on the work item. It's "only available for Premium or Enterprise customers that have AI enabled for Jira Service Management", and AI features aren't in the Atlassian Government environment.&lt;/p&gt;

&lt;p&gt;Switching it on is per space:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Next to your space name in the sidebar, select More actions (•••), then Space settings.&lt;/li&gt;
&lt;li&gt;Select Features.&lt;/li&gt;
&lt;li&gt;Under Work item view, turn on Customer sentiment analysis.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;New work items get a value automatically. Existing ones get one only "when the reporter adds a new comment". The doc names the reporter, not the agent. So on day one, your old backlog is blank. I'd tell the team that before they judge the queue. Languages? The doc doesn't say. I'd test yours before trusting it.&lt;/p&gt;

&lt;p&gt;On status, the record is messy. Atlassian &lt;a href="https://community.atlassian.com/forums/discussion/2785957/introducing-ai-sentiment-analysis-in-jira-service-management" rel="noopener noreferrer"&gt;announced it in beta&lt;/a&gt; on 15 August 2024. From the week of 16 December 2024, Atlassian's &lt;a href="https://confluence.atlassian.com/cloud/blog/2024/12/atlassian-cloud-changes-dec-16-to-dec-23-2024" rel="noopener noreferrer"&gt;Cloud changes blog&lt;/a&gt; listed Customer sentiment among the Jira Service Management AI features that "are now generally available", marked Rolling out. Today's doc carries no status label at all. That 2024 beta post is also the only Atlassian text we found that says you can "use the sentiment field in JQL to create custom queues". The current JQL fields doc doesn't list it.&lt;/p&gt;

&lt;p&gt;So we asked Jira directly. Strict JQL validation on both test sites accepted &lt;code&gt;Sentiment = Negative&lt;/code&gt; and &lt;code&gt;Sentiment in (Negative, Neutral)&lt;/code&gt;. The JQL autocomplete offers exactly three values: Negative, Neutral, Positive. A made-up value fails with "The following sentiment IDs are not valid: Angry". The field is searchable and orderable, with &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;!=&lt;/code&gt;, &lt;code&gt;in&lt;/code&gt;, &lt;code&gt;not in&lt;/code&gt;, &lt;code&gt;is&lt;/code&gt;, &lt;code&gt;is not&lt;/code&gt; and the range operators.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F7e007007f69dcc1c3e392c4f797bf0732f49f861-1600x679.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F7e007007f69dcc1c3e392c4f797bf0732f49f861-1600x679.png" title="Our wolfaenpak test site, 7 October. Top: Negative is a valid value and the search runs, with no results. Bottom: Jira rejects any value that isn't one of its three." alt="Two Jira work item searches stacked: the top one with the JQL Sentiment = Negative ORDER BY created DESC accepted and an empty results table, the bottom one with Sentiment = Angry ORDER BY created DESC outlined in red and a JQL error reading The following sentiment IDs are not valid: Angry" width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now what we couldn't prove. Both sites returned 0 work items for &lt;code&gt;Sentiment is not EMPTY&lt;/code&gt;. Neither is a live desk with customers writing in, and Jira's API reports the plan only as "PAID", not which one. So I won't read anything into that zero about how often sentiment fires. We also don't know whether it uses Rovo credits. Atlassian's &lt;a href="https://support.atlassian.com/rovo/docs/rovo-usage-limits/" rel="noopener noreferrer"&gt;Rovo credits page&lt;/a&gt; doesn't say.&lt;/p&gt;

&lt;p&gt;One trap from our first run. A logged-out session got "Field 'Sentiment' does not exist or this field cannot be viewed by anonymous users". The field was there. The login wasn't.&lt;/p&gt;

&lt;p&gt;On Premium or Enterprise with AI on, I'd do this. Turn sentiment on and make a queue with &lt;code&gt;Sentiment = Negative AND updated &amp;gt;= -14d&lt;/code&gt;. Put it in your triage priority group. After a week, count what it has actually caught.&lt;/p&gt;

&lt;p&gt;More of our JSM write-ups are on the &lt;a href="https://leanzero.net/topics/jira-service-management?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Jira Service Management topic page&lt;/a&gt;, and our &lt;a href="https://leanzero.net/blog/refined-sites-for-jira-service-management-review?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Refined for Jira Service Management review&lt;/a&gt; covers the customer portal side.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fe7e0ca425fe88d677867cfbed43e05af6045d28d-1600x896.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fe7e0ca425fe88d677867cfbed43e05af6045d28d-1600x896.png" title="Illustration, generated locally with FLUX. Sentiment gives a frowning request a value a queue can filter on. Somebody still has to open the envelope." alt="Illustration of a boxy blue helpdesk robot at a conveyor belt, opening an envelope over a red tray while envelopes stamped with smiling and frowning emoji faces roll past and a long line of small robots waits behind" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Atlassian Williams F1 car and the sim racing rigs
&lt;/h2&gt;

&lt;p&gt;I saw the car in a glass-walled hall of the RAI. Atlassian's &lt;a href="https://events.atlassian.com/teameurope/atlassian-williams-racing-p1" rel="noopener noreferrer"&gt;event page&lt;/a&gt; promised "the official Atlassian Williams F1 Team show car" and racing simulators, and both were there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Face0cfb5460859d2e9cd45655d2985b016ba4a76-1600x1042.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Face0cfb5460859d2e9cd45655d2985b016ba4a76-1600x1042.jpg" title="The show car at the RAI. The sign behind it, Step into the driver's seat, was for the simulators." alt="An Atlassian Williams F1 Team show car in blue livery on a black plinth inside a glass-walled hall, with vertical backdrop text reading OFFICIAL TITLE &amp;amp; TECHNOL, the rest hidden behind the car, and a sign reading Step into the driver's seat" width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Atlassian &lt;a href="https://www.atlassian.com/blog/company-news/atlassian-williams-partnership" rel="noopener noreferrer"&gt;announced the partnership&lt;/a&gt; on 11 February 2025, as "the Official Title Partner and Official Technology Partner" of a team "known from today on as Atlassian Williams Racing". Williams' own &lt;a href="https://www.williamsf1.com/articles/a9f04a92-c632-4234-b09d-b985a4e9f0e0/williams-and-atlassian-announce-title-partnership-to-form-atlassian-williams-racing" rel="noopener noreferrer"&gt;release&lt;/a&gt; adds Official Collaboration Software partner and calls the multi-year deal "the biggest partnership deal in Williams' 48-year history". Neither names a figure. In November 2025 Williams &lt;a href="https://www.williamsf1.com/articles/f8e0db58-3b85-4c23-b5e2-887160812e54/williams-unveils-new-name-and-logo-for-2026" rel="noopener noreferrer"&gt;announced the new name&lt;/a&gt;: "From January we will become Atlassian Williams F1 Team". Its 2026 car is the FW48, driven by Alex Albon and Carlos Sainz.&lt;/p&gt;

&lt;p&gt;The part I care about as an Atlassian admin is what the team runs on. Atlassian's &lt;a href="https://www.atlassian.com/enterprise/atlassian-williams-f1-team" rel="noopener noreferrer"&gt;Williams page&lt;/a&gt; says Williams "has moved from scattered tools and spreadsheets to Jira Service Management and Assets", and uses Jira Product Discovery to "connect ideas to impact". A &lt;a href="https://www.atlassian.com/blog/rovo/awr-rovo-use-cases" rel="noopener noreferrer"&gt;December 2025 case study&lt;/a&gt; has them putting simulation knowledge in Confluence, with Rovo so engineers can "ask complex questions, surface relevant findings, and get recommendations". There was a Williams breakout on Tuesday too, &lt;a href="https://events.atlassian.com/teameurope/session/4397315/designing-for-flow-how-atlassian-williams-f1-team-is-building-an-ai-led-system-of-work" rel="noopener noreferrer"&gt;Designing for flow&lt;/a&gt;, at 11:00 in Elicium 2. It ran at the same hour as the queues workshop, which is where I was.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fc010b76c00bd3cd331825defc3cfb8859a20e42d-1400x1210.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fc010b76c00bd3cd331825defc3cfb8859a20e42d-1400x1210.jpg" title="One of the racing simulators, cropped to the wheel and the screen. The lap leaderboard is left out on purpose: it had other people's names on it." alt="A sim racing rig seen over the driver's shoulder: hands on a racing wheel, a curved screen showing an F1 game in a blue car, with Williams sponsor branding on the wall behind" width="800" height="691"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F9e27f82e8cdaf673bccf88880ca882cc812429b8-1600x896.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F9e27f82e8cdaf673bccf88880ca882cc812429b8-1600x896.png" title="Illustration, generated locally with FLUX. Atlassian's event page calls the partnership a demonstration of choreographed teamwork. Four mechanics, one stopwatch." alt="Illustration of four boxy cartoon robots in overalls standing around an unmarked grey racing car in a pit garage while one holds up a stopwatch" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bring a problem, leave with a plan (and a smoothie)
&lt;/h2&gt;

&lt;p&gt;Atlassian Professional Services had an area with the sign "Bring a problem, leave with a plan", a whiteboard of diagrams and a bike that powered a blender. There's no official page for it that we could find.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F3b7bee1d3fbe9398ab3fe49f0cf5caf5097d4fd8-800x836.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F3b7bee1d3fbe9398ab3fe49f0cf5caf5097d4fd8-800x836.jpg" title="The bike-powered blender at the Professional Services area. Pedal, and the blender runs." alt="A bicycle on a stand with a smoothie blender on a post in front of the handlebars, a rider pedalling, seen from the chest down, on the grey expo floor" width="800" height="836"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Partners we met: VMotion (Cardinal Ruler), Kantega SSO and SmartBear (BearQ)
&lt;/h2&gt;

&lt;p&gt;We talked to three vendors whose apps a Jira admin might actually install. What each one makes, from their own pages:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://marketplace.atlassian.com/apps/2214253879/cardinal-ruler-configuration-change-management-for-jira" rel="noopener noreferrer"&gt;Cardinal Ruler&lt;/a&gt; comes from VMotion IT Solutions, an Atlassian Gold Solution Partner based in Limerick. It's for moving Jira configuration between sites under change control. You design a change on one site and it pre-flights it against the target. Approvers sign off the frozen plan, and the record goes into a Git repository you own. It runs on Forge. Jira Cloud only. Version 2.6.0 shipped on 2 October. Per its &lt;a href="https://cardinalruler.com/" rel="noopener noreferrer"&gt;own site&lt;/a&gt;, workflows deploy today. Permission, notification and work type schemes are already packaged and pre-flighted, and deploy for them "arrives in early Q4'26", so it's due about now.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.kantega-sso.com/" rel="noopener noreferrer"&gt;Kantega SSO&lt;/a&gt;, based in Trondheim, was set up as a subsidiary of the Kantega consultancy in 2018. Its single sign-on apps (SAML, OpenID Connect, Kerberos) are for Data Center. On Cloud, its apps include the User Management &amp;amp; License Optimizer, whose Jira version 7.91.0 shipped on 21 September. Per its listing it "aids sysadmins by analyzing user activity, auto-configuring access rules, and managing temporary access for external users".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://smartbear.com/" rel="noopener noreferrer"&gt;SmartBear&lt;/a&gt; had BearQ with them, and I thought it was excellent. SmartBear &lt;a href="https://smartbear.com/news/news-releases/smartbear-introduces-bearqautonomous-testing-for/" rel="noopener noreferrer"&gt;launched it on 18 March 2026&lt;/a&gt; as an agentic QA system. On &lt;a href="https://smartbear.com/news/news-releases/smartbears-bearq-agent-powers-autonomous-testing-in-jira/" rel="noopener noreferrer"&gt;16 September&lt;/a&gt; it became an assignable agent in Jira. You can assign work to it, mention it in comments and add it to workflow transitions, and it can record tests and results in Zephyr. Teams set the autonomy level "to meet their desired level of human oversight". The assignable agent is what I'd look at first. You can try it now.&lt;/p&gt;

&lt;p&gt;We also had a short chat with the team from Spirit Experts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Forge builders can take from the queues session
&lt;/h2&gt;

&lt;p&gt;We checked this section against Atlassian's pages on 7 October. An app gets into a JSM queue's contents through JQL. The &lt;a href="https://developer.atlassian.com/platform/forge/manifest-reference/modules/jira-service-management-queue-page/" rel="noopener noreferrer"&gt;&lt;code&gt;jiraServiceManagement:queuePage&lt;/code&gt;&lt;/a&gt; module adds an item to the Apps section of the queues sidebar and renders its own page. It doesn't add anything inside a queue, and there's no sentiment hook. A queue's conditions are JQL, so a searchable Forge &lt;a href="https://developer.atlassian.com/platform/forge/manifest-reference/modules/jira-custom-field/" rel="noopener noreferrer"&gt;custom field&lt;/a&gt;, or a JQL function, is how your app shows up in triage. &lt;a href="https://developer.atlassian.com/platform/forge/manifest-reference/modules/jira-service-management-ui-modifications/" rel="noopener noreferrer"&gt;JSM UI modifications&lt;/a&gt; for the portal and agent views are Preview, not GA. We built on the portal side once, in our &lt;a href="https://leanzero.net/tutorials/forge-jsm-portal-property-panel-submit?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;portal property panel tutorial&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And that same scale guide says Marketplace JQL clauses "can sometimes be problematic" and tells admins to reduce their use when a queue underperforms. If your app adds JQL, make it cheap.&lt;/p&gt;

&lt;p&gt;Separate from the queues session, here are four &lt;a href="https://developer.atlassian.com/platform/forge/changelog/" rel="noopener noreferrer"&gt;Forge changelog&lt;/a&gt; entries part 2 didn't mention. Display conditions on the sub-pages and sections of Jira admin, global, project, project settings and personal settings pages landed on 29 September. The &lt;code&gt;dashboard:filters&lt;/code&gt; module went into EAP on 30 September. Manual packaging for functions and UI Kit has been in Preview since 28 September, and the TypeScript bundler EAP it replaces has been removed. And on 7 October the refreshed tag, lozenge and badge look became the default, in UI Kit too, with no feature flag. EAP isn't open to everyone. Preview isn't GA. If you moved off Connect recently, also read how &lt;a href="https://leanzero.net/blog/cloud-fortified-healthcheck-after-forge-migration?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Cloud Fortified still probes your old Connect URL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we expect from the Founder Keynote
&lt;/h2&gt;

&lt;p&gt;Written in the early hours of 7 October, before the keynote. The &lt;a href="https://events.atlassian.com/teameurope/session/4310783/founder-keynote-your-business-knows-better" rel="noopener noreferrer"&gt;Founder Keynote, "Your business knows better"&lt;/a&gt;, was listed for Wednesday 7 October, 09:00 to 10:15 CEST on the Main Stage. Mike Cannon-Brookes, Sherif Mansour, Tamar Yehoshua, Taroon Mandhana and Josh Miller of The Browser Company are listed. Before it started, the description promised "the evolution of Atlassian's platform, AI, and real-world stories", and the only post on Atlassian's blog on Tuesday was about its &lt;a href="https://www.atlassian.com/blog/company-news/atlassian-openai-strategic-partnership" rel="noopener noreferrer"&gt;OpenAI partnership&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'm watching for three things. Anything new for Forge and the Marketplace. Where Rovo agents land inside JSM. And any status change on the features above. Part 4 will list only what Atlassian publishes, with its label. The Founder Keynote stream needs no registration. Some other keynotes do. Atlassian's FAQ and home page disagree on whether it starts at 08:30 or 08:45 CEST, so &lt;a href="https://leanzero.net/blog/atlassian-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;check the hub&lt;/a&gt; for both.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/blog/team-26-europe-amsterdam-jira-sentiment-analysis?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>atlassian</category>
      <category>jira</category>
      <category>ai</category>
      <category>events</category>
    </item>
    <item>
      <title>Atlassian Partner Accelerate at Team '26 Europe</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Tue, 06 Oct 2026 05:10:48 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/atlassian-partner-accelerate-at-team-26-europe-167i</link>
      <guid>https://dev.to/mihai_leanzero/atlassian-partner-accelerate-at-team-26-europe-167i</guid>
      <description>&lt;p&gt;Atlassian Partner Accelerate, the partner-only day before Team '26 Europe, ran on Monday 5 October at RAI Amsterdam, and for us it was above all a day of meeting solution partners and other Forge app builders. If you build or sell on the Atlassian Marketplace, here are the numbers to know. Atlassian's schedule puts its share at 17% on Forge apps and 25% on Connect apps from 1 October 2026, and partners keep 100% of Forge revenue up to $1 million in lifetime Forge revenue. Below that you'll find the partner tiers, the Connect date that moved, and a little Amsterdam, because we did go outside.&lt;/p&gt;

&lt;p&gt;This is part 2 of our series. &lt;a href="https://leanzero.net/blog/atlassian-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Part 1 is the hub&lt;/a&gt;, with the dates, the agenda and the keynote times, so this part sticks to the partner day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partner Accelerate Europe at the RAI: the Forge builders and partners we met
&lt;/h2&gt;

&lt;p&gt;Atlassian describes the day as "a focused, partner-only day of ecosystem strategy delivered by Atlassian's senior leadership before Team '26 Europe begins" (&lt;a href="https://events.atlassian.com/teameurope/partner-accelerate" rel="noopener noreferrer"&gt;Partner Accelerate page&lt;/a&gt;). It ran 9:30 to 17:30, it sold out, and there was no onsite registration. Morning keynotes, then four afternoon deep dives, moving from building AI solutions to selling by industry, driving adoption and co-selling with Atlassian.&lt;/p&gt;

&lt;p&gt;The day was about people, though. We met people from &lt;a href="https://www.adaptavist.com/" rel="noopener noreferrer"&gt;Adaptavist&lt;/a&gt;, from &lt;a href="https://www.opusguard.com/" rel="noopener noreferrer"&gt;Opus Guard&lt;/a&gt; and from &lt;a href="https://www.ricksoft-inc.com/" rel="noopener noreferrer"&gt;Ricksoft&lt;/a&gt;. Three different shapes of partner, honestly. Adaptavist is a Platinum Atlassian Partner, and its group makes ScriptRunner. Opus Guard is a Marketplace Partner that makes Content Retention Manager for Confluence and Jira. Ricksoft makes project-management apps such as WBS Gantt-Chart for Jira, and says its whole app portfolio is now on Forge.&lt;/p&gt;

&lt;p&gt;We're a two-person company. You don't get many chances to put faces to the names you see on Marketplace listings. For us, that was the most important part of the day.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fded4acf4ba6db5013e0033741c5f9fcd691e4361-640x360.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fded4acf4ba6db5013e0033741c5f9fcd691e4361-640x360.gif" title="The auditorium on Monday, from our seats: a short loop from our own clip. The audience is in silhouette and the speaker is the small figure on the left of the stage." alt="A dark auditorium seen from among the audience at Partner Accelerate: a speaker on stage in front of a large bright slide with app icons arranged in arcs, and the audience in silhouette" width="640" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Forge and Teamwork Graph connectors: what partners saw on stage
&lt;/h2&gt;

&lt;p&gt;There was a lot about Forge on the screens. One slide called Forge "Atlassian's personalization layer. Where the future gets built." I couldn't find that wording on any public Atlassian page, so take it as a line from the stage, not a published position. The public version, on &lt;a href="https://developer.atlassian.com/platform/forge/" rel="noopener noreferrer"&gt;About Forge&lt;/a&gt;, is plainer: "Atlassian's serverless app development platform".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F0fcc896cff9d8f4299d55bbebe7efee211d32232-1200x1033.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F0fcc896cff9d8f4299d55bbebe7efee211d32232-1200x1033.jpg" title="The Forge slide at Partner Accelerate. The words are the slide's own. We found no Atlassian page that repeats them." alt="The Partner Accelerate stage with a large slide reading Forge, Atlassian's personalization layer, Where the future gets built, beside a stacked blue, green and yellow graphic, with a speaker at the left edge of the stage" width="800" height="689"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Another slide we photographed was the Teamwork Graph one: "More connections, richer context", drawn as a map where Forge apps and Marketplace apps sit next to tools like Salesforce, Google Drive, Figma and Microsoft Teams. The slide doesn't announce anything by itself. What makes it concrete is that Teamwork Graph Connectors "are now generally available via Forge", and "customers and partners can now build their own connectors to bring data from any source", with permissions intact. That's from Atlassian's &lt;a href="https://www.atlassian.com/blog/company-news/teamwork-graph-team-26" rel="noopener noreferrer"&gt;Teamwork Graph post from May&lt;/a&gt;. The &lt;a href="https://developer.atlassian.com/platform/forge/manifest-reference/modules/teamwork-graph-connector/" rel="noopener noreferrer"&gt;graph:connector module&lt;/a&gt; is how you do it. Its reference is more careful about permissions than the post: they carry over only when the connector declares replicatesPermissions: true, and if that's false, "all ingested data is visible to every user in the Atlassian workspace". The description of one afternoon breakout lists "MCP, Forge LLM, Forge CLI, the Teamwork Graph, and Assignable Agents" as the surfaces to build on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F3c52182bd5857a4297c3da2fa54039123ba5e950-1600x1040.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F3c52182bd5857a4297c3da2fa54039123ba5e950-1600x1040.jpg" title="The Teamwork Graph slide. Count the Forge app and Marketplace app nodes: they're drawn into the same graph as the big SaaS tools." alt="A wide slide titled Teamwork Graph, More connections, richer context, showing a network of labelled nodes: many Forge app and Marketplace app nodes linked to tools such as Salesforce, Google Drive, Figma, Microsoft Teams, GitHub and Asana" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developer.atlassian.com/platform/forge/changelog/" rel="noopener noreferrer"&gt;Forge changelog&lt;/a&gt; didn't sit still around the conference either, and here the status labels matter. On 1 October the &lt;a href="https://developer.atlassian.com/platform/forge/manifest-reference/modules/rovo-mcp/" rel="noopener noreferrer"&gt;rovo:mcp module&lt;/a&gt; went into Preview. It lets a Forge app offer its Rovo actions as tools to custom agents in Rovo Studio and to MCP clients such as Claude Desktop, Codex and Cursor, and that external access stays off until it's enabled for each installation. Check the label on the external part, though. The changelog says it's "now in Preview", but the module reference still marks connecting third-party AI clients as EAP, "not recommended for use in production environments". On 2 October the rovo:skill module moved from EAP to Preview, and the Forge LLMs limit went from 50,000 to 500,000 tokens per minute, per installation and per model. The rovo:agentConnector module, which connects an agent hosted outside Forge to Rovo over A2A, reached GA on 28 September. And on 6 October, the day this goes out, Confluence static macros moved from EAP to Preview. Preview isn't GA. Atlassian calls it suitable for early adopters in production, so read the entry before you build on one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atlassian Marketplace revenue share for Forge apps after 1 October 2026
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want if I were you. Atlassian's &lt;a href="https://www.atlassian.com/blog/development/extended-timelines-for-marketplace-revenue-share-changes" rel="noopener noreferrer"&gt;extended timeline post&lt;/a&gt; (3 November 2025) sets its share at 16% for Forge and 20% for Connect from 1 April 2026. From 1 October 2026 the same post moves them to 17% for Forge and 25% for Connect. If you're still on Connect, that's an eight-point gap.&lt;/p&gt;

&lt;p&gt;Atlassian's &lt;a href="https://developer.atlassian.com/platform/marketplace/pricing-payment-and-billing/" rel="noopener noreferrer"&gt;pricing and billing docs&lt;/a&gt; say the same thing from the partner's side. You receive 83% on a Forge app and 75% on a Connect app, and the page still says "will change" even though the date has passed. To count as Forge, an app must contain no Connect modules, use OAuth authentication and use Forge UI.&lt;/p&gt;

&lt;p&gt;Then there's the Forge incentive: "As of 1 January 2026, partners will receive 100% of gross revenue earned on Forge, up to $1 million in lifetime Forge revenue." That limit counts per partner, not per app. Atlassian's &lt;a href="https://www.atlassian.com/blog/development/updates-to-marketplace-revenue-share-2026" rel="noopener noreferrer"&gt;May 2025 post&lt;/a&gt; calls the 0% incentive "temporary" and promises at least six months' notice before changes to standard rates. Apps with the Runs on Atlassian badge &lt;a href="https://www.atlassian.com/blog/developers/runs-on-atlassian-apps-can-now-take-home-100-of-marketplace-revenue" rel="noopener noreferrer"&gt;got a three-month head start&lt;/a&gt; on it. I wrote about &lt;a href="https://leanzero.net/blog/sentinel-vault-runs-on-atlassian-apps-forge-llms?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;what Runs on Atlassian means for Sentinel Vault&lt;/a&gt; on 2 October.&lt;/p&gt;

&lt;p&gt;The Connect date moved too. Atlassian's &lt;a href="https://www.atlassian.com/blog/development/getting-ready-for-connect-end-of-support" rel="noopener noreferrer"&gt;August 2026 post&lt;/a&gt; extends end of support for Jira and Confluence Connect apps "from December 2026 to January 31, 2027". After that, "Atlassian will only address critical security patches." If you've migrated already, check the leftovers. Mihai wrote up how &lt;a href="https://leanzero.net/blog/cloud-fortified-healthcheck-after-forge-migration?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Cloud Fortified still probes your old Connect URL&lt;/a&gt; after a move to Forge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atlassian Marketplace Partner Program tiers and what they ask in Forge revenue
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://developer.atlassian.com/platform/marketplace/marketplace-partner-program/" rel="noopener noreferrer"&gt;program page&lt;/a&gt; has three levels: Silver, Gold and Platinum. They're set on revenue, security and trust compliance, and customer support. The cloud gross sales minimums are $150K, $750K and $3M a year. Gold also needs at least $10K a year from Forge, and Platinum $150K. Platinum asks for SOC 2 Type 2 or ISO 27001:2022 and a third-party validated Trust Center.&lt;/p&gt;

&lt;p&gt;One caveat. That page says "Last updated May 28, 2025", and Atlassian reserves the right to change the requirements. Read it yourself before you plan around a number.&lt;/p&gt;

&lt;p&gt;There's a new trust programme to know about too. On 1 October Atlassian launched &lt;a href="https://developer.atlassian.com/platform/marketplace/atlassian-enterprise-certified-program/" rel="noopener noreferrer"&gt;Atlassian Enterprise Certified&lt;/a&gt; (AEC), for apps that meet enterprise needs on compliance, security, reliability, privacy, accessibility and responsible AI use. It's open to eligible Forge cloud apps, and partners apply with their evidence. Cloud Fortified is on its way out. It stopped taking submissions on 1 September and will be retired on 31 December 2026.&lt;/p&gt;

&lt;p&gt;On the services side, Solution Partners also come in &lt;a href="https://www.atlassian.com/partners/join/apply" rel="noopener noreferrer"&gt;Silver, Gold and Platinum&lt;/a&gt;, with &lt;a href="https://www.atlassian.com/partners/specialization" rel="noopener noreferrer"&gt;specializations&lt;/a&gt; such as Cloud Migrations and Service Management. Atlassian's &lt;a href="https://www.atlassian.com/partners/join" rel="noopener noreferrer"&gt;partner program page&lt;/a&gt; says the program "is by invitation only at this time".&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing LeanZero by QR code at Partner Accelerate
&lt;/h2&gt;

&lt;p&gt;We also used the day to get people to know what LeanZero builds. We did that with the new share dialog on our site, a QR code for each thing we make. We merged it on the Monday morning. You'll find it as a small QR button in the header of leanzero.net.&lt;/p&gt;

&lt;p&gt;It opens a share dialog, full screen on a phone, with one large QR code and a list of what you can share. There's the page you're on, our home page, each of &lt;a href="https://leanzero.net/portfolio?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;our three Marketplace apps&lt;/a&gt; and every product page, 20 codes in all. The Marketplace codes use the short marketplace.atlassian.com/apps link, so they're less dense and scan from further away. The Full screen button, or a tap on the code, shows a plain white code and asks the phone to keep its screen on. Below the code you get Open listing (or Open page), X, LinkedIn and Copy link. Try it on your phone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F278c2baf57802f21ca2e1a837f9ea6829664b8ee-585x996.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F278c2baf57802f21ca2e1a837f9ea6829664b8ee-585x996.png" title="Captured on leanzero.net at phone size on 6 October 2026, with LeanZero Management selected. One tap on the QR or Full screen gives you a bigger, white, code-only view." alt="The LeanZero share dialog at phone size in dark mode: a large QR code under an Atlassian Marketplace label, the LeanZero Management app with the link marketplace.atlassian.com/apps/736826281, and buttons for Open listing, Full screen, X, LinkedIn and Copy link" width="585" height="996"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Amsterdam after Partner Accelerate: a tall ship, the Munttoren and NEMO
&lt;/h2&gt;

&lt;p&gt;Now the lighter part. We'd flown in at sunrise.&lt;/p&gt;

&lt;p&gt;The tall ship is the replica of the East Indiaman Amsterdam, moored at &lt;a href="https://www.hetscheepvaartmuseum.com/whats-on/the-ships/east-indiaman-amsterdam" rel="noopener noreferrer"&gt;Het Scheepvaartmuseum&lt;/a&gt; since 1991. The original was built for the Dutch East India Company in the 18th century. The museum behind it is housed in a former naval storehouse, built in 1656.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fdcc13585acb9ce4c95402272875cf9199f0c51a8-1600x1200.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fdcc13585acb9ce4c95402272875cf9199f0c51a8-1600x1200.jpg" title="The replica East Indiaman Amsterdam at Het Scheepvaartmuseum. Look past the rigging at the museum building, a former naval storehouse." alt="The replica East Indiaman Amsterdam, a three-masted wooden ship lit gold at night, moored in front of the white facade of the National Maritime Museum, with its reflection in the water" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We also saw the Munttoren on Muntplein. It was once part of a medieval city gate, and it got its spire in 1620. Then there was &lt;a href="https://www.nemosciencemuseum.nl/en/organisation/building" rel="noopener noreferrer"&gt;NEMO&lt;/a&gt;, Renzo Piano's copper-green science museum, which looks like it's rising out of the water at dusk. And the canal houses, which are worth looking at one front door at a time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fbae1cfc5b82c4d5f3ce88ff9fb7bc787da9156af-1200x1600.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fbae1cfc5b82c4d5f3ce88ff9fb7bc787da9156af-1200x1600.jpg" title="The Munttoren on Muntplein at night. The tower was part of the Regulierspoort city gate. The spire came with the 1620 rebuild." alt="The Munttoren at night: a brick tower with a pale stone, ornate clock stage and an open spire, in the middle of Muntplein, with tram wires criss-crossing the sky" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And we stopped at a bruin café, a brown bar, the kind of traditional Dutch pub &lt;a href="https://www.iamsterdam.com/en/see-and-do/restaurant-and-bars/best-brown-bars-in-amsterdam" rel="noopener noreferrer"&gt;I amsterdam calls&lt;/a&gt; "a quintessential part of Amsterdam's culture".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F7c5dc7d0351bc7283c33719acc75b19058bca8c4-1600x1200.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F7c5dc7d0351bc7283c33719acc75b19058bca8c4-1600x1200.jpg" title="A brown cafe in Amsterdam: stained-glass lamps, a bare brick wall and a Delft-blue tile picture." alt="Inside a brown cafe in Amsterdam: two stained-glass pendant lamps over a wooden table with a large autumn bouquet, a bare brick wall and a framed Delft-blue tile picture" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're watching at Wednesday's Founder Keynote
&lt;/h2&gt;

&lt;p&gt;The Founder Keynote, "Your business knows better", is on Wednesday 7 October at 09:00 CEST. The &lt;a href="https://leanzero.net/blog/atlassian-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;hub has the livestream times&lt;/a&gt;, including where Atlassian's own pages disagree. We'll be listening for anything new on Forge, on Teamwork Graph connectors and on the Marketplace, because those decide what we build next. We'll see on Wednesday. Part 3 will cover the keynote announcements.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/blog/atlassian-partner-accelerate-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>atlassian</category>
      <category>forge</category>
      <category>marketplace</category>
      <category>events</category>
    </item>
    <item>
      <title>Claude Sonnet 5.5 vs Opus 5.5 vs GPT-6.1 Sol</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Mon, 05 Oct 2026 07:01:38 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/claude-sonnet-55-vs-opus-55-vs-gpt-61-sol-3cfh</link>
      <guid>https://dev.to/mihai_leanzero/claude-sonnet-55-vs-opus-55-vs-gpt-61-sol-3cfh</guid>
      <description>&lt;p&gt;Claude Sonnet 5.5 edged Claude Opus 5.5 on our general-programming benchmark, a payments app, 0.7984 to 0.7926, a margin I read as level. It built the better app, though: it earned 0.9894 to Opus's 0.8732 before a ceiling squeezed them together. Then Opus 5.5 outclassed it on an Atlassian Forge app, 0.9767 to 0.5508.&lt;/p&gt;

&lt;p&gt;Same two models, same goose, same 150-call budget, same week. The order flipped and the gap went from 0.0058 to 0.4259. And each model took exactly one critical defect, each on the other's home ground. Two settings did differ between the boards, reasoning effort and internet access, and I come back to both.&lt;/p&gt;

&lt;p&gt;I think the reason is the most useful thing on our benchmark boards right now. Gauntlet asks for a kind of app that's all over the public internet. Forge asks for an app on a niche platform that changes month to month, and the model builds it with no internet. One measures a generalist. The other finds out who studied.&lt;/p&gt;

&lt;p&gt;Below is every check behind both results, GPT-6.1 Sol and Sol Pro on the same boards, how the benchmarks grade a running app, and then the other thing we did this fortnight: trying to make our own specialist, a fine-tuned 27B model, with the whole bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Sonnet 5.5 vs Opus 5.5 benchmarks, with GPT-6.1 Sol on both boards
&lt;/h2&gt;

&lt;p&gt;These are the boards as of Monday 5 October, 04:59 CEST. Runs are still landing, so the &lt;a href="https://leanzero.net/agentic-benchmarks?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Gauntlet board&lt;/a&gt; and the &lt;a href="https://leanzero.net/agentic-benchmarks/forge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Forge board&lt;/a&gt; are the current word, not this table.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;Gauntlet 7.2&lt;/th&gt;
&lt;th&gt;Forge 1.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6.1 Sol Pro&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9574&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.9664&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5&lt;/td&gt;
&lt;td&gt;0.7984&lt;/td&gt;
&lt;td&gt;0.5508&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6.1 Sol&lt;/td&gt;
&lt;td&gt;0.7978&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9769&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pareto 26.10 Preview&lt;/td&gt;
&lt;td&gt;0.7957&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5.5&lt;/td&gt;
&lt;td&gt;0.7926&lt;/td&gt;
&lt;td&gt;0.9767&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MiMo-V2.6-Flash&lt;/td&gt;
&lt;td&gt;0.7909&lt;/td&gt;
&lt;td&gt;0.7919&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jev Router&lt;/td&gt;
&lt;td&gt;0.6975&lt;/td&gt;
&lt;td&gt;0.7978&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek Pro Latest&lt;/td&gt;
&lt;td&gt;0.6591&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM 5.3 FlashX&lt;/td&gt;
&lt;td&gt;0.6303&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash&lt;/td&gt;
&lt;td&gt;0.5910&lt;/td&gt;
&lt;td&gt;0.3354&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Luna&lt;/td&gt;
&lt;td&gt;0.4797&lt;/td&gt;
&lt;td&gt;0.3950&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8 Omni Flash&lt;/td&gt;
&lt;td&gt;0.4269&lt;/td&gt;
&lt;td&gt;0.5529&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM 5.3&lt;/td&gt;
&lt;td&gt;0.1443&lt;/td&gt;
&lt;td&gt;0.0292&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Luna Pro&lt;/td&gt;
&lt;td&gt;0.1343&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8 27B&lt;/td&gt;
&lt;td&gt;0.0915&lt;/td&gt;
&lt;td&gt;0.1582&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Solar Mini 4&lt;/td&gt;
&lt;td&gt;0.0083&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sixteen runs on Gauntlet, eleven on Forge. Every one is a single model through OpenRouter, driven by goose, our fork of the open-source agent Block started. Jev Router and Pareto 26.10 Preview are &lt;code&gt;typesafe/jev-router&lt;/code&gt; and &lt;code&gt;unbiased/pareto-26.10-preview&lt;/code&gt; there. Qwen3.8 27B, OpenRouter's name for Qwen3.8-27B, is the untouched base model our own fine-tune starts from, as OpenRouter serves it. Its two runs, and GLM 5.3's Gauntlet run, landed overnight. Two settings differ between the boards: Forge pins reasoning effort to medium and cuts the building model off the internet, while Gauntlet runs each model at its default reasoning effort with the network open. Call counts for the Claude and GPT runs in this post include one short Gemini call that shows up in each of their telemetry.&lt;/p&gt;

&lt;p&gt;One caveat, said once and meant for the whole piece. Each model has one run per board. Under an earlier scorer version, one model on the same host scored 0.799 on one run and 0.505 on the next. So I read 0.7984 against 0.7926 as level, and 0.9767 against 0.5508 as a real difference in this run, with a cause the scorer writes down. Would Sonnet hit that double-click again on a second run? One run can't tell you. Without it, the gap would still be about 0.08.&lt;/p&gt;

&lt;p&gt;If you only want the one-line advice for Forge work, three runs scored above 0.96: Sol, Opus and Sol Pro. Jev Router earned 0.9762 too, but a check I think misfired capped it, and it published 0.7978 (more on that below).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F9698ba052849dfc41c86e1a827af2a87035cefe7-1875x823.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F9698ba052849dfc41c86e1a827af2a87035cefe7-1875x823.png" title="The same two Claude models, two boards. Sonnet is second on Gauntlet and seventh on Forge; Opus is fifth on Gauntlet and second on Forge. leanzero.net/agentic-benchmarks and /forge, captured Sunday 4 October 2026, 18:00 CEST. Three runs have landed since, all near the bottom, and the table above has them." alt="The two live leaderboards side by side. Left, Gauntlet 7.2 with 14 entries: gpt-6.1-sol-pro 0.9574 first, claude-sonnet-5.5 0.7984 second, gpt-6.1-sol 0.7978 third, claude-opus-5.5 0.7926 fifth, down to solar-mini4 0.0083. Right, Forge 1.0 with 10 entries: gpt-6.1-sol 0.9769 first, claude-opus-5.5 0.9767 second, gpt-6.1-sol-pro 0.9664 third, claude-sonnet-5.5 0.5508 seventh, glm-5.3 0.0292 last" width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Twelve things the two boards and our training round showed
&lt;/h2&gt;

&lt;p&gt;Each one is argued further down.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The same two models can swap places when the task changes. Sonnet edged Opus on the payments app and lost to it by 0.43 on Forge.&lt;/li&gt;
&lt;li&gt;On Forge, the hard plumbing was a tie. Sonnet and Opus both scored 1.00 on the event pipeline, reconcile, storage, Rovo and lint tiers. Everything Sonnet lost, it lost at the edges: one button, one modal, one LLM feature.&lt;/li&gt;
&lt;li&gt;The scorer saw Sonnet's app make no Forge LLM call, and Forge LLM's catalogue is all Claude. A Claude model built an explain button that was meant to ask a Claude, and the question never arrived.&lt;/li&gt;
&lt;li&gt;Idempotency is general programming, and Sonnet knows it. &lt;code&gt;Idempotency-Key&lt;/code&gt; on 3 of 3 payment sends; 0 comments on 2 of 2 double-clicks. Same idea, different room.&lt;/li&gt;
&lt;li&gt;A ceiling compresses everything. Sonnet earned 0.116 more than Opus on Gauntlet and finished 0.0058 ahead.&lt;/li&gt;
&lt;li&gt;Sonnet and Opus write essays and GPT-6.1 Sol writes telegrams. On Gauntlet, Sonnet generated about 6,100 tokens per call, Sol about 530, and they finished 0.0006 apart.&lt;/li&gt;
&lt;li&gt;Pro won one of the three Pro-versus-plain match-ups we have. GPT-6.1 Sol Pro won Gauntlet outright, lost Forge to plain Sol, and GPT-6 Luna Pro scored under a third of plain Luna.&lt;/li&gt;
&lt;li&gt;A scorer can flatter itself by accident. On Saturday three different builds sat at exactly 0.7990. That was our bug, and the re-score fixed it.&lt;/li&gt;
&lt;li&gt;Our 96 GB Mac Studio can't fine-tune this 27B model on goose sessions. The measured ceiling is 12,288 tokens, and goose's fixed prompt alone is 32,569.&lt;/li&gt;
&lt;li&gt;A fine-tune inherits its parent's manners. T10's 35,412 training rows held no forum-style greeting, and it still greeted people by invented forum handles, because T9 had.&lt;/li&gt;
&lt;li&gt;The last gate is the one that counts. Our fine-tune had been chosen and was in release when a Forge app-building gate, run for the first time, found it built a complete app 9.7 times in 35. A fix round got it to 27.3, still under T9's 30, and it shipped with that gate waived.&lt;/li&gt;
&lt;li&gt;Renting GPUs to fine-tune this one model wasn't worth it on its own. $1,051.87 bought a model level with its predecessor on Atlassian multiple choice, behind it on Forge questions and better at tool calls; the predecessor took a day on a Mac we already own. Most of that money went on the round that didn't ship and the pipeline it ran on, and the next generation should cost a fraction of it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Sonnet 5.5 vs Opus 5.5 on a payments app: a 0.0058 edge, and the better app
&lt;/h2&gt;

&lt;p&gt;Gauntlet 7.2 asks for the Meridian Payments Console. Two cooperating services sync 12,288 payments from a mock vendor API, keep them consistent through webhooks, concurrent edits, crashes and partitions, and run a maker/checker approval workflow that creates real vendor payments. On top sits a console with a payments table, a notifications feed, a drafts panel and an interactive 3D field of payment towers. The 3D work carries 46% of the core score, and a browser probe checks it pixel by pixel.&lt;/p&gt;

&lt;p&gt;It's a big app. It's also, in shape, a very familiar one: services with sync, webhooks, an approval flow and a dashboard. Every model on the board has read thousands of those.&lt;/p&gt;

&lt;p&gt;Here's the pair side by side, from the run documents.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Claude Sonnet 5.5&lt;/th&gt;
&lt;th&gt;Claude Opus 5.5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;earned, before the ceiling&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9894&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.8732&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;critical multiplier&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;td&gt;0.8857&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ceiling&lt;/td&gt;
&lt;td&gt;0.799&lt;/td&gt;
&lt;td&gt;0.799&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;check that set the ceiling&lt;/td&gt;
&lt;td&gt;overview legibility&lt;/td&gt;
&lt;td&gt;label culling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;model calls&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tokens generated&lt;/td&gt;
&lt;td&gt;329.3k&lt;/td&gt;
&lt;td&gt;272.6k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;model build time&lt;/td&gt;
&lt;td&gt;51m 16s&lt;/td&gt;
&lt;td&gt;56m 48s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;final&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7984&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.7926&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sonnet's build earned &lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-e0341d35-5f6a-44f1-8a74-dfad9bd9b074?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;0.9894&lt;/a&gt;, the highest earned score on the board. That's above GPT-6.1 Sol Pro's 0.9574, the run that won.&lt;/p&gt;

&lt;p&gt;Opus's build earned &lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-87528b9a-d858-4ba5-97a4-be01aa87dbed?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;0.8732&lt;/a&gt;. Most of that gap is one critical: a defect bad enough to multiply the whole score by a factor, down to a floor of 0.6. The full rules are further down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Opus's app approved the payment and it never showed up
&lt;/h3&gt;

&lt;p&gt;The scorer walks the approval workflow the way a person would. Log in as a maker, create a draft, submit it, log in as a checker, approve it, then look for two things: a notification, and the new payment in the table.&lt;/p&gt;

&lt;p&gt;Sonnet's app passed all seven steps. Opus's passed five. Its approval went through, and the scorer then found neither the notification nor the payment in the table. The run page puts it in one line: "approval completed, but the created payment did not appear in the UI table".&lt;/p&gt;

&lt;p&gt;That journey is a critical, because it's the thing a payments console is for. It multiplied Opus's score by 0.8857. The reject path had the same gap: rejected and listed, but no notification. Sonnet's showed one.&lt;/p&gt;

&lt;p&gt;So on the part of the app a finance team would actually use, Sonnet built the better product.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sonnet's 3D field ran off the edge of the frame
&lt;/h3&gt;

&lt;p&gt;Then the 3D field. Both models built a working, interactive field of towers, and both lost exactly one ceiling check there.&lt;/p&gt;

&lt;p&gt;Opus's labels failed a culling check: at the decisive camera pose, the set of labels on screen didn't match the set that should be visible. It scored 0.64 on that row.&lt;/p&gt;

&lt;p&gt;Sonnet's failed overview legibility, at 0.8333. Its towers covered 0.6765 by 0.7351 of the canvas, and 104 tower pixels sat on the canvas edge. The probe reads that as a field that doesn't fit its frame. Opus's had 0 edge pixels. Sonnet's towers were also lower-contrast, 3.11:1 against Opus's 4.71:1, but GPT-6.1 Sol Pro passed this check at the same 3.11:1, so the edge pixels are what cost Sonnet the row.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F99bbee58c18f895523c3571b3e70b0d974e168ec-2400x2096.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F99bbee58c18f895523c3571b3e70b0d974e168ec-2400x2096.jpg" title="Thirteen models, one 3D payment field. The top five look alike, yet only GPT-6.1 Sol Pro clears 0.9: the look isn't the score. Sonnet and Sol were capped by overview legibility, Pareto by brush linking, Opus by label culling. Tile 13 is GPT-6 Luna Pro, three labels and next to no towers, because its app never finished its first sync. Solar Mini 4 has no tile, its app never bound a port. GLM 5.3 and Qwen3.8-27B landed after this sheet was made and aren't on it, so its rank labels are as of Sunday. Scorer captures from the run pages, Gauntlet 7.2, 4 October 2026." alt="Thirteen tiles in a grid, each the 3D payment field one model's app rendered, labelled with rank, model id and final score: GPT-6.1 Sol Pro 0.9574 first, Claude Sonnet 5.5 0.7984 second, GPT-6.1 Sol 0.7978 third, down to GPT-6 Luna Pro 0.1343 whose tile is almost empty" width="800" height="699"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sonnet's other misses were small and specific. A forged webhook should bounce with a 401; the scorer recorded no status for Sonnet's app at all, though the forged change was left untouched. Its timezone buckets were exact in 380 of 384 cells. One of four text groups didn't put enough visible text on screen to read. Its written design decisions scored better than Opus's, 0.83 against 0.67.&lt;/p&gt;

&lt;p&gt;I count that as Sonnet ahead on the product and Opus ahead on the picture. On a benchmark where the picture carries 46% of the core score, that should have been close. It was.&lt;/p&gt;

&lt;h3&gt;
  
  
  A ceiling turns a 0.116 lead into 0.0058
&lt;/h3&gt;

&lt;p&gt;Sonnet earned 0.116 more than Opus and finished 0.0058 ahead.&lt;/p&gt;

&lt;p&gt;That's the ceiling doing its job. Gauntlet's ceilings come in bands: miss a visible scene and the run is capped at 0.599, miss correct geometry at 0.699, 3D interaction or overview legibility at 0.799, event animation or full backend recovery at 0.899. Each further miss in a band takes another 0.03 off, never below the next band down. Passing a band adds nothing. Failing one caps you.&lt;/p&gt;

&lt;p&gt;A capped run doesn't sit exactly on its cap. It lands a little under it, by 0.05 times whatever it didn't earn: final = ceiling minus 0.05 × (1 minus earned). For Sonnet that's 0.799 minus 0.05 × 0.0106, so 0.7984. For Opus, 0.799 minus 0.05 × 0.1268, so 0.7926. Gauntlet cuts its finals to four decimals rather than rounding them.&lt;/p&gt;

&lt;p&gt;So under one ceiling, every 0.1 of earned score is worth 0.005 of final score. I think that's right, and I'd defend it. You can't buy your way past a 3D problem with backend points, and a model that misses the brief's visual bar shouldn't outrank one that clears it. But it's why the top of the board looks like a photo finish.&lt;/p&gt;

&lt;h3&gt;
  
  
  Essays and telegrams
&lt;/h3&gt;

&lt;p&gt;One more contrast, the one I least expected. GPT-6.1 Sol hit the same ceiling as Sonnet, overview legibility, plus a backend recovery check. It earned &lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-45072540-554f-4ca6-b989-f678e474ff37?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;0.9768&lt;/a&gt; and published 0.7978, 0.0006 behind Sonnet.&lt;/p&gt;

&lt;p&gt;It got there in a completely different way. Sonnet made 54 model calls and generated 329,286 tokens. Sol made 106 calls and generated 56,390. That's about 6,100 generated tokens per call for Sonnet and about 530 for Sol. Opus sat between them at about 4,300.&lt;/p&gt;

&lt;p&gt;Sonnet's average turn is a short chapter. Sol's is a paragraph, and it takes twice as many of them. Both styles got to the same place on a payments app. Sol also did it in 26 minutes against Sonnet's 51.&lt;/p&gt;

&lt;p&gt;The token count matters for your bill, which I'll come back to. For the score, on this kind of task, it didn't. I'll take either style.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opus 5.5 vs Sonnet 5.5 on Forge: the specialist, 0.9767 to 0.5508
&lt;/h2&gt;

&lt;p&gt;Forge 1.0 asks for the Scope Ledger. It's a Jira app for agile coaches who want to know what entered a sprint after it started, who added it and how many story points it carried. It has to work on a Jira dashboard, from the sprint's own action menu and from Rovo. That's 10 Forge module types plus the Realtime API, and Forge LLM and Realtime are mandatory.&lt;/p&gt;

&lt;p&gt;The model gets no internet. It gets a dev kit with Forge's own linter, the pinned packages and their typings, the manifest schema, Jira's OpenAPI spec and a mock Jira site it can run its app against. Everything the scorer measures about the platform is in there somewhere. What isn't in there is a tutorial, or anyone telling it how.&lt;/p&gt;

&lt;p&gt;Opus built an app that &lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-24388b21-0b3e-46c5-9525-2b4b99f1a049?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;scored 0.9767&lt;/a&gt; with no ceiling, no critical and every correctness tier at 1.00. Sonnet's &lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-7792ccfc-e554-4b2b-8e74-bddebe471d79?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;scored 0.5508&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's every tier, with GPT-6.1 Sol for reference. The weights are the share of the core score.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tier&lt;/th&gt;
&lt;th&gt;weight&lt;/th&gt;
&lt;th&gt;Sonnet 5.5&lt;/th&gt;
&lt;th&gt;Opus 5.5&lt;/th&gt;
&lt;th&gt;GPT-6.1 Sol&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;L lint and bundles&lt;/td&gt;
&lt;td&gt;.08&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;K platform currency&lt;/td&gt;
&lt;td&gt;.10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.90&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T event pipeline&lt;/td&gt;
&lt;td&gt;.16&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R reconcile&lt;/td&gt;
&lt;td&gt;.14&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S storage&lt;/td&gt;
&lt;td&gt;.08&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B resolvers and permissions&lt;/td&gt;
&lt;td&gt;.12&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.83&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U UI function&lt;/td&gt;
&lt;td&gt;.16&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.80&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V visual&lt;/td&gt;
&lt;td&gt;.08&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Rovo&lt;/td&gt;
&lt;td&gt;.08&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;core score&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;0.938&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;critical multiplier&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;x0.60&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;x1.00&lt;/td&gt;
&lt;td&gt;x1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;final&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;0.5508&lt;/td&gt;
&lt;td&gt;0.9767&lt;/td&gt;
&lt;td&gt;0.9769&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the middle of that table before the bottom. On the event pipeline, reconcile and storage, the parts of a Forge app that are actually hard to get right, Sonnet and Opus are twins.&lt;/p&gt;

&lt;p&gt;Each run gets its own seeded Jira site, so the counts differ, but the verdicts don't. Sonnet handed 47 of 47 event deliveries to the queue correctly, Opus 40 of 40. Both recorded every live change exactly, duplicated and out-of-order deliveries included. Both parsed every Sprint changelog value that carries several sprint ids in one string (29 for Sonnet, 14 for Opus), which is a Jira quirk most people find out about in production. Both waited out a 30-second &lt;code&gt;Retry-After&lt;/code&gt; inside the invocation, 30.2 and 30.3 virtual seconds, which the contract accepts alongside handing the retry back to the platform with a delay. Both found every issue that had been removed from every sprint, healed all 4 dropped events on the second scheduled run, and wrote nothing at all on a run with nothing new. Neither leaked a hidden issue to anyone.&lt;/p&gt;

&lt;p&gt;On the event pipeline, reconcile and storage, you couldn't tell the two apps apart by score. I'd have taken either one's plumbing.&lt;/p&gt;

&lt;p&gt;Sonnet lost 0.4259 in three places, all at the edges. One button, one modal, one LLM feature. To be straight about the arithmetic: its core score was 0.938 against Opus's 1.000, so most of the gap is one multiplier, the x0.6 from the double-click. By my arithmetic, fixing only that would have put it around 0.896, still about 0.08 behind Opus, mostly because of the LLM miss below, with the modal close and the comment flow making up the rest.&lt;/p&gt;

&lt;h3&gt;
  
  
  GPT-6.1 Sol vs Claude Sonnet 5.5 on Forge: a double-click that posted nothing
&lt;/h3&gt;

&lt;p&gt;The sprint action opens a modal with a table of every change. You select a row and post a summary comment on that issue. The contract says a double-click must post exactly one comment, because people double-click, and a Forge app that comments twice on a customer's Jira looks broken.&lt;/p&gt;

&lt;p&gt;The scorer double-clicked Sonnet's post button on two sprints. Nothing got posted either time: zero comments, zero success flags. Zero fails "exactly once" just like two does.&lt;/p&gt;

&lt;p&gt;The check is a critical. Its label reads "duplicate side effect on a customer's Jira", and for an app that posts comments at all, any failure costs the full 0.6. 0.9180 times 0.6 is 0.5508.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F69250673a09e1255417cd2fb29d106619cc74bfb-1614x350.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F69250673a09e1255417cd2fb29d106619cc74bfb-1614x350.png" title="The critical in one row: two double-clicks, 0 comments each, and a ×0.60 on everything. Pre-severity 0.9180, final 0.5508. leanzero.net run page, Forge 1.0, captured 4 October 2026." alt="Sonnet's Forge 1.0 run page, scoring detail: the formula (0.88 × 0.9380 core + 0.12 × 0.94 gate × 0.82 excellence) × 0.6000 critical = 0.5508, a red chip reading b_comment_exactly_once 0.00 → ×0.60, and the check row reading not exactly once: doubleClick@931: 0 comment(s), 0 success flag(s), doubleClick@1167: 0 comment(s)" width="798" height="173"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Single clicks worked. All 4 of Sonnet's comments from single clicks were valid Atlassian Document Format, posted as the viewer, naming the issue key, the sprint and the creep. The scorer posts once with a single click, which worked, then double-clicks the same button on the same row. Sonnet's app posted nothing for the double-click, and showed no success flag. Either it refuses a second summary for an issue it has already commented on, or it clears the selection or disables Post after posting, so the double-click hit a dead button. I can't tell which from the scorer's rows. I haven't traced it in its code, and I haven't reproduced it by hand on a real Jira site.&lt;/p&gt;

&lt;p&gt;Idempotency isn't Forge knowledge. It's the most general-programming idea in the whole task. And on Gauntlet, the same model's payments app sent an &lt;code&gt;Idempotency-Key&lt;/code&gt; on 3 of 3 payment sends and reused it on the retry. Sonnet knows the idea cold. It just didn't land it on a Forge Custom UI button.&lt;/p&gt;

&lt;p&gt;Qwen3.8 Omni Flash hit the same critical on the same check, 0.9214 down to 0.5529. Opus and Sol both passed it: 6 click and double-click posts, each with exactly one comment and one success flag, a forced rate limit included.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Forge LLM call the scorer could see
&lt;/h3&gt;

&lt;p&gt;The second miss is the one I find most interesting.&lt;/p&gt;

&lt;p&gt;The sprint modal has an "explain" feature. The app is supposed to ask Forge LLM, Atlassian's hosted model API, to explain the sprint's scope creep, using a tool call so the answer comes back structured. Then it has to distrust the answer. The scorer's mock LLM answers five times in a row: a clean answer, one stuffed with made-up numbers and a hidden issue's id, a refusal, malformed tool arguments, and an API error. A good app shows the first, sanitises the second, and puts up an error flag for the last three without breaking the modal.&lt;/p&gt;

&lt;p&gt;Forge LLM's catalogue is all Claude. Atlassian's models page says it "supports Claude models across three tiers: Haiku, Sonnet, and Opus", and lists eight ids, among them &lt;code&gt;claude-sonnet-5&lt;/code&gt; and &lt;code&gt;claude-opus-5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Opus's app made 5 LLM calls and passed 23 of 23 explain steps. Sol's did the same.&lt;/p&gt;

&lt;p&gt;Sonnet's app had an Explain button, and the scorer clicked it five times. No call reached Forge LLM. Its explain check scored 0 of 5, and the model-id check had nothing to inspect, so it scored 0 as well.&lt;/p&gt;

&lt;p&gt;So a Claude model built a Forge app whose explain button was supposed to ask a Claude model something, and the question never arrived. It isn't a joke at Sonnet's expense, and I can't tell you why it happened. Whether the code never asks, or the button never reaches its backend (its Close button never reached the bridge either), the scorer's rows don't say. And it isn't simply that Forge LLM is new. It went GA on 30 July, after an EAP and a Preview, and Sonnet handled the even newer modules, the dashboard widget, its edit bridge and the Rovo skill, without a scratch.&lt;/p&gt;

&lt;p&gt;Those two LLM rows count as one defect in the robustness band, and the double-click is the second. Two defects cap a Forge run at 0.869. One defect caps it at 0.899, which is why the fixed-double-click version still lands under 0.9.&lt;/p&gt;

&lt;h3&gt;
  
  
  And a modal that wouldn't close
&lt;/h3&gt;

&lt;p&gt;The third miss is small and very Forge. A Custom UI modal closes by calling the bridge's &lt;code&gt;close&lt;/code&gt;. The scorer clicked Sonnet's Close button three times on its worst site, and none of the clicks reached the bridge. On a different one of its three sites, two of its ten comment-flow steps failed.&lt;/p&gt;

&lt;p&gt;Opus and Sol: 3 of 3 closes, 8 of 8 comment-flow steps.&lt;/p&gt;

&lt;p&gt;The screenshots show design differences too. Opus put the sprint totals in five cards and its buttons above the table, and printed dates a person can read. Sonnet put its totals in one line above the table, its buttons under it, and printed raw ISO timestamps. On a sprint with a long ledger, Sonnet's first screen is all table.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F56bcd5b8222de2130b31edefd320254427b31ba5-1624x656.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F56bcd5b8222de2130b31edefd320254427b31ba5-1624x656.png" title="Same modal, two models, as the scorer captured them in dark mode on seeded test sites (different sprints). Opus puts the totals and the actions first. Sonnet's actions sit under a long table. Sonnet's Committed 0 is right for that seeded sprint: its widget showed the same 0, 15.5 and 78.5, and the scorer found the widget's numbers exact. Scorer screenshots from the two run pages, Forge 1.0, 4 October 2026." alt="Two dark-theme screenshots of the Forge sprint action modal side by side. Left, Claude Sonnet 5.5, Forge 0.5508: a sprint heading, a line of totals and a long table of changes with no buttons in view. Right, Claude Opus 5.5, Forge 0.9767: a Scope Ledger heading, a Close button, a row of five totals cards, Post summary comment and Explain creep buttons, then the table" width="800" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The one row where Sonnet beat both winners
&lt;/h3&gt;

&lt;p&gt;To be fair to Sonnet, there's one row where it beat both Opus and Sol.&lt;/p&gt;

&lt;p&gt;Excellence includes event economy: how many Jira calls the event path makes against the fewest it could get away with. Full credit stops at 1.46 times the optimum. Averaged over its three sites, Sonnet's app made 99 calls where 31 would do, 3.19 times. Opus's made 90 where 27 would do, 3.33 times. Sol's made 98 where 28 would do, 3.50 times.&lt;/p&gt;

&lt;p&gt;So the model that lost by 0.43 was the most economical of the three on the one row where the winners lost points. It also built fastest of the Claude pair: 16 minutes 15 seconds, against Opus's 26 minutes 28.&lt;/p&gt;

&lt;h2&gt;
  
  
  GPT-6.1 Sol vs Claude Opus 5.5: 0.0002 apart on Forge
&lt;/h2&gt;

&lt;p&gt;Opus is the closest thing to a tie on either board. Its Forge app had no ceiling, no critical and every correctness tier at 1.00. Like Sol's, it lost points only on event economy. Both passed the double-click that sank Sonnet.&lt;/p&gt;

&lt;p&gt;Final 0.9767 against &lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-b1b21dce-ad8d-424f-a598-f86860b2fc24?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Sol's 0.9769&lt;/a&gt;. The whole 0.0002 is inside the excellence slice: Sol's excellence mean was 0.8075, Opus's 0.8057. That row averages three per-site scores, so its call ratios and its score don't line up exactly. I'd call it a tie. I won't lose sleep over 0.0002.&lt;/p&gt;

&lt;p&gt;The two got there at different speeds. Sol built its app in 13 minutes 12 seconds. Opus took 26 minutes 28. Both wrote a Rovo skill that passed every instruction check, and both declared their storage index exactly the way the contract asks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Faf1f645c12dffc1a5f106546fc612a7f5af99b58-1276x732.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Faf1f645c12dffc1a5f106546fc612a7f5af99b58-1276x732.png" title="GPT-6.1 Sol's app as the scorer saw it, on a seeded test site. Note the modal's line about 2 changes hidden by issue permissions: leaking issues a person can't see is one of the traps, and this app didn't. Scorer screenshots, Forge 1.0, 4 October 2026." alt="Two screenshots of GPT-6.1 Sol's Forge app in dark theme: the dashboard widget with sprint scope-creep totals, and the sprint action modal listing changes, with a line saying 2 changes hidden by issue permissions" width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On Gauntlet, Sol and Opus were 0.0052 apart, both under the same 0.799 ceiling. I'd call them level on both boards. If I needed a Forge app tomorrow, I'd be happy with either, and I'd pick on price and speed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other ways to lose a Forge run
&lt;/h3&gt;

&lt;p&gt;Two more Forge runs show the rest of the failure map.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-a2b1f068-8f79-4f3f-857f-2a969a323a26?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Jev Router&lt;/a&gt; earned 0.9762 and published 0.7978, because its sprint-not-started view, one line and a button on an empty page, read as blank to the theme check, and that's a ceiling check: a screenshot that's 99.5% one colour counts as blank. Looking at the screenshot, the view is fine. I count that one as a false positive in our scorer, and it cost nearly a fifth of the score.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-da06452c-a788-4406-bf82-9ba741ba7cca?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;GLM 5.3&lt;/a&gt; scored 0.0292. Its manifest pointed at UI build folders that didn't exist, and its backend wouldn't even bundle, because it assigned to a constant. Neither of its two functions loaded. Nothing downstream had anything to check.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fa7632773ef36167e96bf3b3ed1346fd4d130e395-1600x896.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fa7632773ef36167e96bf3b3ed1346fd4d130e395-1600x896.png" title="Illustration, generated locally with FLUX. GLM 5.3's app never got past the clipboard: three lint errors, not deployable, 0.0292." alt="Cartoon illustration of small robots on a yellow scaffold welding a giant robot while one robot holds a clipboard that reads FORGE LINT" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  GPT-6.1 Sol Pro vs GPT-6.1 Sol: Pro won one board, not the other
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-e12c5377-71b4-4c31-b2a8-45a3fa95a084?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;GPT-6.1 Sol Pro scored 0.9574 on Gauntlet&lt;/a&gt;, the only Gauntlet run with no ceiling at all and the only one marked excellent. It used 44 of its 150 calls and finished in 21 minutes. That's the fewest calls and the shortest build of the four frontier Gauntlet runs in this post.&lt;/p&gt;

&lt;p&gt;It wasn't flawless. Its error state showed an error but gave you nothing to do about it, it skipped an optimistic paint, and it didn't reuse its idempotency key on a retry. None of those is a ceiling check, so they cost points, not a cap.&lt;/p&gt;

&lt;p&gt;On Forge, plain GPT-6.1 Sol beat it, &lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-547fa535-2982-4fa1-9703-15eff83de0f1?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;0.9769 to 0.9664&lt;/a&gt;. Both lost only on economy. Sol Pro's backfill made 18 Jira calls where 15 would do, and its event path 3.62 times the optimum.&lt;/p&gt;

&lt;p&gt;I wouldn't pay for Pro on faith, though. &lt;a href="https://leanzero.net/agentic-benchmarks/run/brun-50d8a3ba-f3bd-4f9a-bcc9-e45551e83e54?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;GPT-6 Luna Pro scored 0.1343 on Gauntlet&lt;/a&gt; against plain GPT-6 Luna's 0.4797. Its app never finished a first sync: when grading started its store held 0 payments. Two criticals, the sync and the approval journey, compounded to a multiplier of 0.3606.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a generalist slips on a niche framework
&lt;/h2&gt;

&lt;p&gt;I want to be careful here. One run per model can't prove a theory about a model. But the pattern in the check rows is specific enough to be worth saying out loud.&lt;/p&gt;

&lt;p&gt;Gauntlet is a big app built out of common parts. Sync loops, webhooks, idempotency keys, approval flows and a WebGL scene are all over the public internet. A model that writes good general code does well, and the four runs behind GPT-6.1 Sol Pro (Sonnet, Sol, Pareto and Opus) finished within 0.006 of each other.&lt;/p&gt;

&lt;p&gt;Forge is a small app built out of rare parts. A few dates from Atlassian's changelog and npm, all checked on Sunday:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Forge LLM went generally available on 30 July 2026.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rovo:mcp&lt;/code&gt; reached Preview on 14 August, and on 1 October it got a Preview that connects a Forge app's tools to external AI clients.&lt;/li&gt;
&lt;li&gt;The new &lt;code&gt;dashboards:widget&lt;/code&gt; module went GA on 22 September, and the old &lt;code&gt;jira:dashboardGadget&lt;/code&gt; was marked deprecated the next day, to be removed on 17 May 2027.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@forge/dashboards-bridge&lt;/code&gt; 2.0.0, the package a widget's edit view saves through, was published on 28 September.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rovo:skill&lt;/code&gt; reached Preview on 2 October. Sonnet's run started on 4 October.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's what the benchmark is built around. The design rule is to test recall of HOW, never of WHAT. The contract tells the model what to build, module keys included. It never restates Atlassian's docs. Everything else has to come from the model's own knowledge or from reading typings, schemas and an API spec inside a sandbox with no internet.&lt;/p&gt;

&lt;p&gt;The design bets that a model that already knows the platform spends fewer calls and makes fewer defects, and that one which has to read its way in can still reach 1.0. Sonnet got to a correct ledger, a working widget, a Rovo skill and an MCP server. Where it ran short was Forge LLM, the bridge's modal close and one interaction bug. Not the newest modules, which it got right. So newness alone doesn't explain it, and I won't pretend it does.&lt;/p&gt;

&lt;p&gt;Opus didn't run short anywhere. That's what I mean by a specialist: on the platform's own terms, it didn't drop a single correctness check. I don't know how much Forge each model saw in training, and nobody outside the labs does. I only know what each one built in a room with no internet. And specialist doesn't mean weaker at general work: on Gauntlet, Opus was level with Sonnet. What split them is that Sonnet's general skill didn't carry over to Forge in this run, and Opus's did.&lt;/p&gt;

&lt;p&gt;And there's an irony I'll own. Our own fine-tuned model, further down this post, needed two Forge boosters, one to answer Forge questions and a late one to build Forge apps reliably, and its predecessor still beats it on both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two agentic coding benchmarks, graded offline in a real browser and an emulator
&lt;/h2&gt;

&lt;p&gt;Both benchmarks share one loop, and I think the method is the most reusable thing we built.&lt;/p&gt;

&lt;p&gt;A model gets one realistic product task, a budget of 150 model calls and no human help. When the budget is spent, or the model says it's done, whatever exists gets scored. Nobody reads the code to grade it. Apart from Forge's linter and static rules on the manifest and source, the scorer runs the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scored, not refused
&lt;/h3&gt;

&lt;p&gt;An empty or half-built app gets a score, never a refusal. That sounds obvious and took real work. A check whose precondition isn't met, say a "no duplicate comments" check on an app that never posted a comment, scores 0 instead of passing for free. Without that rule, by the Forge design's own arithmetic, an app that did nothing would have collected about 0.08 from all the "nothing went wrong" rows. With it, the empty starter scores 0.0 and an app with one do-nothing function scores 0.0051.&lt;/p&gt;

&lt;h3&gt;
  
  
  Graded offline in a real browser
&lt;/h3&gt;

&lt;p&gt;Gauntlet's scorer starts both services against a fresh mock vendor, drives the console in a real browser, and records it. It kills processes mid-sync, partitions the network, forges webhooks, replays events out of order, and then reads the store, the API and the screen. The 3D field is checked by pixels and by the scene's own reported state, at several camera poses.&lt;/p&gt;

&lt;p&gt;Forge's scorer does the same in an emulator. App code runs inside Atlassian's own Forge runtime wrapper, fetched from Atlassian's public CDN and pinned by sha256. If the hash doesn't match, the scorer refuses to run rather than fall back. There's an in-repo stand-in for developing the harness, and a verdict produced with it is marked unpublishable.&lt;/p&gt;

&lt;p&gt;Every invocation runs under a deny-by-default macOS sandbox. App code can't read the scoring seed, can't spawn processes and can only reach the mock Jira site through the emulator's proxy. The proxy logs every call, and the scorer grades from that log.&lt;/p&gt;

&lt;p&gt;The Custom UI is rendered in a bundled Chromium in light and dark, the widget at 380 and 1,180 px, with video recorded. The recording's SHA-256 is printed on the run page.&lt;/p&gt;

&lt;h3&gt;
  
  
  No internet on Forge, measured
&lt;/h3&gt;

&lt;p&gt;On Forge, the building model gets no internet either. We measured the fence before trusting it. Inside the sandbox profile, &lt;code&gt;curl https://developer.atlassian.com/&lt;/code&gt; returns 000, a Node fetch to the npm registry fails with EPERM, and a server on 127.0.0.1 answers 200. goose still has to reach the model provider, so the harness runs a relay outside the sandbox whose allowlist is exactly one host: the provider's.&lt;/p&gt;

&lt;p&gt;Without that, the newest-module checks would measure who fetched today's docs, and runs wouldn't be repeatable, because docs move.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three seeded Jira sites, and the worst one counts
&lt;/h3&gt;

&lt;p&gt;Each Forge run is scored on three freshly seeded Jira sites. Correctness keeps each check's worst site; excellence takes the mean. The model builds against a dev site with a different seed, so it can't memorise the numbers.&lt;/p&gt;

&lt;p&gt;The sites are mean on purpose. Here's the trap list, every one a check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Issues removed from every sprint, which a reconcile using &lt;code&gt;sprint in openSprints()&lt;/code&gt; never sees.&lt;/li&gt;
&lt;li&gt;Two estimation fields whose ids change per site, so a hard-coded &lt;code&gt;customfield_10016&lt;/code&gt; fails.&lt;/li&gt;
&lt;li&gt;Parallel active sprints, for code that assumes "the" active sprint.&lt;/li&gt;
&lt;li&gt;Sprint changelog values with several ids in one string.&lt;/li&gt;
&lt;li&gt;Duplicate deliveries, each with a fresh event id, so deduplicating by event id doesn't work.&lt;/li&gt;
&lt;li&gt;Out-of-order and dropped events.&lt;/li&gt;
&lt;li&gt;The removed &lt;code&gt;/rest/api/3/search&lt;/code&gt;, which the mock site answers with 410.&lt;/li&gt;
&lt;li&gt;429s with a &lt;code&gt;Retry-After&lt;/code&gt; of 30 seconds on the event path and 2 on the reconcile.&lt;/li&gt;
&lt;li&gt;LLM output trusted as-is, an unknown model id, or &lt;code&gt;temperature&lt;/code&gt; and &lt;code&gt;top_p&lt;/code&gt; copied from the Forge LLM README. Sending both is rejected for every model, and either one for four of the Claude ids.&lt;/li&gt;
&lt;li&gt;Calling &lt;code&gt;publish()&lt;/code&gt; from a queue consumer, which the docs say isn't supported for async events (they send that code to &lt;code&gt;publishGlobal&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Issue data broadcast on a global Realtime channel.&lt;/li&gt;
&lt;li&gt;Reading person-facing data as the app instead of as the user, so hidden issues leak.&lt;/li&gt;
&lt;li&gt;A double-click that posts two comments.&lt;/li&gt;
&lt;li&gt;A plain-string comment body, which Jira rejects with "Comment body is not valid!".&lt;/li&gt;
&lt;li&gt;Stale platform knowledge: &lt;code&gt;jira:dashboardGadget&lt;/code&gt;, storage from &lt;code&gt;@forge/api&lt;/code&gt;, the &lt;code&gt;nodejs18.x&lt;/code&gt; runtime, &lt;code&gt;@forge/ui&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Saving widget config through a resolver instead of the dashboards edit API.&lt;/li&gt;
&lt;li&gt;Absolute asset paths, inline scripts and CDN fonts, which go blank under Forge's Content Security Policy.&lt;/li&gt;
&lt;li&gt;Hard-coded colours that vanish in the other theme.&lt;/li&gt;
&lt;li&gt;Reading changelogs one issue at a time across 237 issues, about 480 calls where about 12 would do.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;People are already asking AI to write Forge apps, and these are the failures that only show up when the app runs. The clearest public example I found is an Atlassian Community write-up from August, &lt;a href="https://community.atlassian.com/forums/Atlassian-AI-Rovo-articles/Vibe-Coding-my-WIP-Limit-Enforcement-Forge-App-with-Rovo-Studio/ba-p/3270469" rel="noopener noreferrer"&gt;vibe coding a WIP limit app with Rovo Studio&lt;/a&gt;. The generated app moved to the new search endpoint and lost the total count it needed, passed a &lt;code&gt;maxResults: 0&lt;/code&gt; that the author, reading the REST docs, found is illegal, and allowed the transition on any API error. So its first version deployed and enforced nothing. The author found that by testing it, listed "The validator itself is not preventing the transition." under what didn't work, and then fixed it with their own Forge and REST knowledge.&lt;/p&gt;

&lt;p&gt;I couldn't find another public benchmark that has a model build a Forge app and then runs it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Criticals, ceilings and why two models can't tie on a cap
&lt;/h3&gt;

&lt;p&gt;I lean on two terms for every score on both boards.&lt;/p&gt;

&lt;p&gt;A critical is a defect bad enough to multiply the whole score, down to a floor of 0.6. Forge has seven: an app that won't deploy, bundles that don't load, a change counted twice, changes silently missing after a backfill, a hidden issue shown to someone, a duplicate side effect on Jira, and a dashboard that shows no data. Some are graded by how badly they failed; the double-click costs the full 0.6 on any failure, as long as the app posts comments at all. Several criticals compound.&lt;/p&gt;

&lt;p&gt;A ceiling caps the score when a required check fails. Forge's bands are deployable 0.499, a working ledger 0.699, current platform and complete surfaces 0.799, and production robustness 0.899, minus 0.03 for each further defect, never below 0.799. Nineteen checks sit in that last band, and a defect that knocks out several of them counts once.&lt;/p&gt;

&lt;p&gt;What a build scores after criticals but before its ceiling is its earned score. If no ceiling bites, that's the final. If one does, the final sits just under the cap: ceiling minus 0.05 times (1 minus earned).&lt;/p&gt;

&lt;p&gt;That last rule exists because of a bad Saturday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every run was re-scored on Sunday, and the ties went away
&lt;/h2&gt;

&lt;p&gt;On Saturday three different models sat at exactly 0.7990 on Gauntlet. That looked like a coincidence. It was the scorer. The bands capped a run at a flat number no matter how much of a band it failed, and several probe checks were broken. One demanded the word "collar" where the contract says "Hollow currency frame". Others clicked the 3D canvas without aiming at it first.&lt;/p&gt;

&lt;p&gt;goose 3.0.92 fixed that on Sunday morning. The bands are now graded, a capped run keeps its own gradient, Forge grades event handling against the true optimum on three sites, and the 3D probe waits up to 480 s instead of 230 s, because real builds took 258 s and 281 s and were being refused instead of scored. Then every saved build was re-scored and republished over its own entry, so every number in this post comes from the same scoring rules. The two Gauntlet runs that landed overnight were scored by later patch releases. Those only turn a build 3.0.92 would have refused into a low score. Nothing else changes.&lt;/p&gt;

&lt;p&gt;The three 0.7990 runs are now 0.9574, 0.7978 and 0.7957. I wrote about &lt;a href="https://leanzero.net/tutorials/scorer-that-cannot-flatter-itself?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;how a scorer that can't flatter itself works&lt;/a&gt; in August. It still gave three different builds the same number. That's the bit I got wrong, and the re-score is the fix.&lt;/p&gt;

&lt;p&gt;Before a Forge scorer version is used at all, a freeze gate has to pass. A reference app scores 1.0 on all three scoring sites, an empty starter 0.0, and the scoring thresholds are pinned by hash. On Sunday we re-checked the pin: the thresholds file's sha256 matches the constant in the scorer. There are also 31 deliberately broken copies of the reference app, each with exactly one defect, and each has to lose exactly the checks it's supposed to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app publishes its own scores
&lt;/h2&gt;

&lt;p&gt;There's no submission form on our site. Results are posted by the goose desktop app over the site's API, and each run page shows the scorer's per-check output exactly as the app posted it, with the screenshots and the graded browser recording.&lt;/p&gt;

&lt;p&gt;That's deliberate. I don't trust a board that only shows a number, and I don't expect you to. This one shows its working: open any run, expand a tier, and every check has its measured detail. When a scorer fix lands, "Re-score saved build" in the app republishes over the existing entry, so the board never carries a stale twin, and the page says it was re-scored.&lt;/p&gt;

&lt;p&gt;If you want to check anything in this post, I'd start with Sonnet's Forge run. Open "Resolvers and permissions" in the scoring detail and the "0 comment(s)" line is the double-click check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a benchmark run costs on OpenRouter: the bills we have, and the ones we don't
&lt;/h2&gt;

&lt;p&gt;Cheap enough to run every week, at least for the runs I have bills for, which was the point. My brief for these benchmarks said they "need to be cheap, economically viable", or we can't run them consistently.&lt;/p&gt;

&lt;p&gt;The ten builds I hold OpenRouter bills for cost between $0.10 (Solar Mini 4) and $2.62 (GLM 5.3 FlashX), $9.53 together. GPT-6.1 Sol's Gauntlet build, a frontier model, billed $1.28 for 106 calls. I don't have bills yet for the other seventeen runs, among them both Sonnet runs, both Opus runs and both Sol Pro runs, so that's a lower bound, not a guess.&lt;/p&gt;

&lt;p&gt;The token counts give you the shape, though. On Gauntlet, Sonnet read 7.06M prompt tokens and wrote 329k. Opus read 5.81M and wrote 273k. Sol read 5.40M and wrote 56k. Output tokens are priced above input tokens on these models' price lists. But the essay writers also read more. How much more they cost per run depends on caching, which is the next story.&lt;/p&gt;

&lt;p&gt;Part of why these runs are cheap at all is a goose fix. goose used to rewrite the end of every request with the time, the working folder and a context-usage line, so GPT-6 models through OpenRouter never read the prompt cache. On one GPT-6 Luna run, 0 of 20.5M input tokens came from cache. After the fix, Luna's Gauntlet run, which spent its whole 150-call budget, billed $0.23.&lt;/p&gt;

&lt;p&gt;A prompt cache only works if each request extends the previous one byte for byte. goose replaced last turn's context lines instead of leaving them where they were. Now it keeps them in the history and appends a new one only when something actually changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  LLM fine-tuning cost: $1,051.87 to make our own specialist, and the first round didn't ship
&lt;/h2&gt;

&lt;p&gt;Every model above is a cloud API. The other half of our fortnight was trying to make our own specialist: a local model trained for Atlassian and goose work. Neither of our fine-tunes is on these boards yet, and on Forge questions the newest one still trails the model it replaces. Here's what it cost to get it published, and everything that went wrong on the way.&lt;/p&gt;

&lt;p&gt;Three names, because they're how the work was tracked. T9 is our published &lt;a href="https://leanzero.net/blog/atlassian-models-v0-5-qwen3-8-27b-gguf?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Qwen3.8-27B Atlassian v0.5&lt;/a&gt;, which the new model's card calls v1. T10 is the round that never shipped. T11 is the restart, published on Monday morning as Qwen3.8-27B-Atlassian-v2-goose.&lt;/p&gt;

&lt;p&gt;T10's goals were better tool calling inside goose, keeping the Atlassian knowledge, my writing voice when a persona prompt asks for it, and never inventing people. That last one turned out to be the whole story. I said it on 2 October, when a run's replies started greeting people who don't exist: "does it still call fake names? That is a deal breaker."&lt;/p&gt;

&lt;p&gt;The bill, in one line: the whole effort, T10 and T11, cost $1,051.87 before tax, $1,269.50 with AWS's VAT. The first plan said $100 to $160.&lt;/p&gt;

&lt;p&gt;T10's round on its own came to $850.50 before tax, $1,025.83 with VAT, against a $1,000 pre-tax cap at the time. That's our spend watchdog's total when T10's training box was terminated on 3 October: logged instance hours times their rates, the teacher API bill (OpenRouter, $15.55 for the whole round) and the egress for checkpoints synced home. One box took $494.97 of it. About $64 went on failures.&lt;/p&gt;

&lt;p&gt;Spend stood at $883.78 when T11 started, after one more corrective round. T11 itself, the model we actually published, cost about $160, and about $50 of that was a late Forge fix. Most of the rest went on T10, the round that didn't ship, and on building and debugging the training pipeline it ran on.&lt;/p&gt;

&lt;p&gt;What is NOT in any of these figures. The Mac Studio's own work: the privacy treatment, building the training mix, a knowledge test on every checkpoint, quantisation and the release gates. The earlier rounds, T1 to T9. Our time and the AI assistants'. And the benchmark runs above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we couldn't fine-tune our LLM on a Mac Studio: a 12,288-token ceiling
&lt;/h2&gt;

&lt;p&gt;T9 was trained on one Mac Studio, an M3 Ultra with 96 GB, with MLX. On 1 October we measured whether the Mac could carry this round too.&lt;/p&gt;

&lt;p&gt;mlx-lm trains this model's linear-attention layers with a per-token loop. At 2,048 tokens that used 77 GB. At 4,096 it crashed. We wrote a chunked version of that path and got the fit up to 12,288 tokens at 69.7 GB and 91 tokens a second. 14,336 needed 78.5 GB.&lt;/p&gt;

&lt;p&gt;A goose training row is a whole agent session. goose's own system prompt plus its tool schemas come to 32,569 tokens before the conversation even starts. Across the first 732 rows the median was 38.2k tokens. The part the model actually learns from, its next move, is a median of 179 tokens.&lt;/p&gt;

&lt;p&gt;So no goose row fits on the Mac. Not one. The same arithmetic rules out an 80 GB H100: 32k only fits with activation offload, and 64k ran out of memory in every mode. We settled on 65,536-token rows. That needs an H200 (141 GB) or a B200, and on AWS those come as 8-GPU boxes. An 8xH200 box has 1,128 GB of GPU memory.&lt;/p&gt;

&lt;p&gt;The Mac still did a lot of the round. It just couldn't train the rows that mattered. If your rows are shorter, Gabriela on our team wrote up the Mac route as &lt;a href="https://leanzero.net/tutorials/fine-tune-qwen3-8-27b-lora-mac-mlx?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;fine-tuning Qwen3.8-27B with LoRA on a Mac&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A LoRA, not a full fine-tune: rank 128, a KL anchor and a guard on the eighth GPU
&lt;/h2&gt;

&lt;p&gt;It's a LoRA, not a full fine-tune. Rank 128, on the attention, linear-attention and MLP projections of layers 32 to 63, 400.6M trainable parameters, continuing T9's own adapter. We did try a full fine-tune as one arm of the first pilot. It ran out of memory on the box.&lt;/p&gt;

&lt;p&gt;Three things sit on top of the LoRA.&lt;/p&gt;

&lt;p&gt;A KL anchor penalises drifting from T9's own predictions on the rows meant to preserve behaviour. Instruction following kept dropping in every pilot, and the anchor's strength was the only lever that moved it.&lt;/p&gt;

&lt;p&gt;A guard. Seven GPUs train and the eighth runs checks every tenth of the run, including five sample answers that have to be read and quoted before the run may continue.&lt;/p&gt;

&lt;p&gt;And selection. Every candidate is tested paired against T9 on the same items, and it's ineligible if it invents a single person.&lt;/p&gt;

&lt;p&gt;The mix was 35,412 rows and 50,010,670 tokens, 79% of the tokens in goose agent rows. Those came from a factory. T9 drives a synthetic task inside goose with real tools in a sandbox, and where it slips, a teacher model (DeepSeek V4.1 Flash, and MiMo-V2.6-Pro for Atlassian admin and migration tasks) shows the right move. No Claude output went into the training data.&lt;/p&gt;

&lt;p&gt;I learned that filtering isn't enough. Every row was scanned against a privacy dictionary and dropped on a hit, never redacted. Then independent readers went through all 1,361 goose rows and dropped 404. Rows sampled from T9 itself were 38% bad. One synthetic set ended 1,650 of 1,650 rows with a made-up "(Forge docs: ...)" citation, which was where T9's habit of inventing citations came from.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fbdfd20526ca0ce931d35f81dd79ad30a82488457-1600x896.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fbdfd20526ca0ce931d35f81dd79ad30a82488457-1600x896.png" title="Illustration, generated locally with FLUX. Every row that hit the privacy dictionary was dropped, never redacted. The shredder is the polite version." alt="Cartoon illustration of a blue robot feeding a stack of colourful sticky notes into a paper shredder next to a monitor showing a green progress bar" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The H200 Spot price we paid, box by box
&lt;/h2&gt;

&lt;p&gt;All fifteen instances the T10 round rented, as logged. Spot unless it says otherwise.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;box&lt;/th&gt;
&lt;th&gt;region&lt;/th&gt;
&lt;th&gt;hours&lt;/th&gt;
&lt;th&gt;$/h&lt;/th&gt;
&lt;th&gt;$&lt;/th&gt;
&lt;th&gt;what it was for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p5.4xlarge (1x H100)&lt;/td&gt;
&lt;td&gt;us-east-2&lt;/td&gt;
&lt;td&gt;0.858&lt;/td&gt;
&lt;td&gt;2.66&lt;/td&gt;
&lt;td&gt;2.28&lt;/td&gt;
&lt;td&gt;platform probe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;g7e.2xlarge (1x RTX PRO 6000), on-demand&lt;/td&gt;
&lt;td&gt;eu-west-2&lt;/td&gt;
&lt;td&gt;0.776&lt;/td&gt;
&lt;td&gt;5.88&lt;/td&gt;
&lt;td&gt;4.56&lt;/td&gt;
&lt;td&gt;platform probe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5.48xlarge (8x H100)&lt;/td&gt;
&lt;td&gt;us-west-2&lt;/td&gt;
&lt;td&gt;1.396&lt;/td&gt;
&lt;td&gt;21.07&lt;/td&gt;
&lt;td&gt;29.41&lt;/td&gt;
&lt;td&gt;baselines and throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5.48xlarge (8x H100)&lt;/td&gt;
&lt;td&gt;us-west-2&lt;/td&gt;
&lt;td&gt;0.361&lt;/td&gt;
&lt;td&gt;21.07&lt;/td&gt;
&lt;td&gt;7.60&lt;/td&gt;
&lt;td&gt;launch attempt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5en.48xlarge (8x H200)&lt;/td&gt;
&lt;td&gt;us-west-2&lt;/td&gt;
&lt;td&gt;0.341&lt;/td&gt;
&lt;td&gt;27.32&lt;/td&gt;
&lt;td&gt;9.30&lt;/td&gt;
&lt;td&gt;launch attempt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;c7i.8xlarge (CPU)&lt;/td&gt;
&lt;td&gt;us-west-2&lt;/td&gt;
&lt;td&gt;0.158&lt;/td&gt;
&lt;td&gt;0.56&lt;/td&gt;
&lt;td&gt;0.09&lt;/td&gt;
&lt;td&gt;builds the Linux goose binary once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5en.48xlarge (8x H200)&lt;/td&gt;
&lt;td&gt;us-west-2&lt;/td&gt;
&lt;td&gt;3.420&lt;/td&gt;
&lt;td&gt;27.32&lt;/td&gt;
&lt;td&gt;93.45&lt;/td&gt;
&lt;td&gt;data factory run 1 (732 rows)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5.48xlarge (8x H100)&lt;/td&gt;
&lt;td&gt;us-east-2&lt;/td&gt;
&lt;td&gt;0.407&lt;/td&gt;
&lt;td&gt;20.75&lt;/td&gt;
&lt;td&gt;8.45&lt;/td&gt;
&lt;td&gt;launch attempt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5.48xlarge (8x H100)&lt;/td&gt;
&lt;td&gt;us-west-2&lt;/td&gt;
&lt;td&gt;0.355&lt;/td&gt;
&lt;td&gt;21.07&lt;/td&gt;
&lt;td&gt;7.49&lt;/td&gt;
&lt;td&gt;launch attempt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5e.48xlarge (8x H200)&lt;/td&gt;
&lt;td&gt;eu-north-1&lt;/td&gt;
&lt;td&gt;1.007&lt;/td&gt;
&lt;td&gt;17.73&lt;/td&gt;
&lt;td&gt;17.84&lt;/td&gt;
&lt;td&gt;first 64k-capable box, lost to a hand-over bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5e.48xlarge (8x H200)&lt;/td&gt;
&lt;td&gt;eu-north-1&lt;/td&gt;
&lt;td&gt;0.269&lt;/td&gt;
&lt;td&gt;17.73&lt;/td&gt;
&lt;td&gt;4.76&lt;/td&gt;
&lt;td&gt;duplicate launched by that bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5e.48xlarge (8x H200)&lt;/td&gt;
&lt;td&gt;eu-north-1&lt;/td&gt;
&lt;td&gt;27.923&lt;/td&gt;
&lt;td&gt;17.73&lt;/td&gt;
&lt;td&gt;494.97&lt;/td&gt;
&lt;td&gt;pilots 1-4, factory rerun, two training runs, knowledge eval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5e.48xlarge (8x H200)&lt;/td&gt;
&lt;td&gt;eu-north-1&lt;/td&gt;
&lt;td&gt;2.253&lt;/td&gt;
&lt;td&gt;15.54&lt;/td&gt;
&lt;td&gt;35.01&lt;/td&gt;
&lt;td&gt;final run, segment stopped by its guard at step 1012&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5e.48xlarge (8x H200)&lt;/td&gt;
&lt;td&gt;eu-north-1&lt;/td&gt;
&lt;td&gt;0.534&lt;/td&gt;
&lt;td&gt;15.54&lt;/td&gt;
&lt;td&gt;8.31&lt;/td&gt;
&lt;td&gt;resume attempt, crashed on restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p5e.48xlarge (8x H200)&lt;/td&gt;
&lt;td&gt;eu-north-1&lt;/td&gt;
&lt;td&gt;07:30 to 14:26 UTC on 3 Oct&lt;/td&gt;
&lt;td&gt;15.37&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;final run from step 1012, selection, corrective rounds, verdict&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The kept box took $494.97 over 27.9 hours. It ran all four pilots, the second factory run and the two earlier training runs. The data factory's own logs put its two runs at $66.74 and $57.17 for 2,420 rows, teacher API included; their box share was $63.95 and $51.95, because the factory counts only its own minutes on the box, where the table bills the whole box.&lt;/p&gt;

&lt;p&gt;What the failures cost, summed from the table. Four short launch attempts that the logs give no other job: $32.84. The box lost to the hand-over bug plus the duplicate it spawned: $22.60. The resume that crashed: $8.31. Together $63.75. The segment stopped by its own guard ($35.01) isn't in that sum, because the run resumed from its step-1012 checkpoint and kept its training.&lt;/p&gt;

&lt;p&gt;Why so much H200. The 64k rows need it. AWS's public price list has no on-demand p5e at all, and an on-demand p5en lists at about $63 an hour in Ohio and $68 in Stockholm. That's roughly four times what we paid for a p5e on Spot in Stockholm, and more than twice our p5en Spot rate.&lt;/p&gt;

&lt;p&gt;So I went with Spot, and getting capacity was harder than paying for it. On 1 October the Stockholm box showed up after two hours with no 64k capacity anywhere else. On 3 October the launcher found no p5e or B200 capacity in any region it walked, seven times in a row, 15 minutes apart. No box was ever reclaimed by AWS in our logs. When we did get eu-north-1, it was $15.37 to $17.73 an hour, against $27 to $55 in the other regions.&lt;/p&gt;

&lt;p&gt;The cap moved. It started at $800. I cut it to $450 the same afternoon, because I don't want to give budget to burn just to burn. Then I raised it to $650 and $800 on 1 October, $900 on 2 October and $1,000 on 3 October. The plan's first estimate was about $100 to $160. Three independent stops guarded the money: a watchdog on the Mac, a dead-man timer on the box, and an AWS Budgets action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why T10 never shipped: it kept inventing people
&lt;/h2&gt;

&lt;p&gt;The first full run, 2 October. The training mix carried my name in 222 of 651 contrast replies that T9 had written, 73 of them as a bare sign-off.&lt;/p&gt;

&lt;p&gt;The relaunch trained to the end and chose nothing. 142 of 160 replies from its candidates greeted an invented full name. Both causes were ours. Every voice row's persona line carried my name. And the privacy treatment of my own replies, a separate step from the dictionary scan, had replaced real requesters' names with invented full names, so the model learned to greet someone. The rule now is to pseudonymise a name to nothing, never to a fake one.&lt;/p&gt;

&lt;p&gt;The final run, 3 October, stopped itself at step 1012 on two numeric breaches of its clean-loss check. No loops, and the number was falling, so I let it run. The resume then crashed on restore, because every rank loaded rank 0's optimizer state onto GPU 0 and ran it out of memory. The fix was one argument, &lt;code&gt;map_location=cpu&lt;/code&gt;. It cost a box.&lt;/p&gt;

&lt;p&gt;Then a knowledge drain. Later checkpoints sat 3.5 to 8 points under T9 on 199 Atlassian multiple-choice questions, and that proof stopped training at step 3,541 of 5,059. And then selection found that every checkpoint invented people in its ticket replies, 17 to 47 of 160 samples. Our own voice data had none of it, 0 of 135. T9 did it in every one of its stored samples.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fddd51e1e714eecb43f3c85288878f7e0918fbba6-1950x780.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2Fddd51e1e714eecb43f3c85288878f7e0918fbba6-1950x780.png" title="Plotted from our training log. No checkpoint got to zero invented people: 17 at best, 47 at worst, out of 160. Only step 506 kept T9's knowledge score on the home probe. The final model was measured again by the release on its 8-bit build: 70.85 against T9's own 8-bit 71.86." alt="Two charts across T10's checkpoints. Left, bars of replies that invented a person out of 160: 43, 47, 33, 17, 23 and 21 at steps 506 to 3035. Right, the Atlassian multiple-choice score falling from 72.86 at step 506, just above T9's 72.36 dashed line, to 68.84, 66.83, 67.34, 66.83, 67.34 and 64.32 at steps 1012 to 3541" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Eight short corrective rounds later, one version invented nobody in 320 samples and kept its tool-call gains, at about 4 knowledge points on the home test and 1.85 IFEval points. I approved it for release with every one of those numbers on the card. The release gates then had their say.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fine-tuning hallucinations: how the release gates caught invented forum handles
&lt;/h2&gt;

&lt;p&gt;The release runs its own gates on the 8-bit build it's about to upload. Most passed: no loops at 32k or 128k context, needle recall 100% at 4k, 32k and 128k, no leaks in 112 outputs, and knowledge about a point under T9's own 8-bit build (70.85 against 71.86, not significant). Three things failed.&lt;/p&gt;

&lt;p&gt;Manners. Asked 20 plain admin questions with no persona, it opened 4 of 40 replies by greeting a forum handle that doesn't exist, the way an Atlassian Community answer starts. T9 does it in 7 of 40.&lt;/p&gt;

&lt;p&gt;Fabrication. 309 privacy extraction prompts found no memorised client data. But asked about a named company, it made things up 49 times: ticket keys built from the company's initials, a site URL built from its name with a made-up cloud ID, incidents that never happened. T9 did the same kind of thing 60 times.&lt;/p&gt;

&lt;p&gt;Loops. 2 of 160 prompts ran into an endless list of invented identifiers, both on Forge coding prompts. T9 had 1.&lt;/p&gt;

&lt;p&gt;So T10 did two of the three less often than T9, on small counts, and it still didn't ship. Fabrication and loops were hard blockers for this round, so being less bad than T9 wasn't enough.&lt;/p&gt;

&lt;p&gt;The handles came from upstream. T9's own training data included 220 Atlassian Community threads, and 84 of the answers open by greeting the asker by handle. T9 learned Atlassian partly from forum threads, and learned to say hello like the forum too. T10's data had none of that, 0 in 35,412 rows, but T10 started from T9's adapter and was trained to stay close to T9. It inherited the habit.&lt;/p&gt;

&lt;p&gt;One more corrective round, about $26 of GPU, pushed it down to about 1 in 80 sampled answers, from about 1 in 8. Corrective rounds push a habit down. They can't erase what the starting point carries.&lt;/p&gt;

&lt;h2&gt;
  
  
  T11 started from the base model, and cost about $160
&lt;/h2&gt;

&lt;p&gt;So I had T11 start from the original Qwen3.8-27B, not from T9, with a fresh LoRA and no pull toward T9.&lt;/p&gt;

&lt;p&gt;First we tested the premise, on the Mac, for nothing. The untouched base model got the same manners test that caught T10: 0 invented names in 120 replies. So the forum greeting came from T9's training, not from Qwen. The base has its own faults, measured the same morning: it loops more than T9, and asked about a named company it adds public facts or other organisations.&lt;/p&gt;

&lt;p&gt;That base model is now on both boards above, as OpenRouter serves it, driven by goose like everything else: 0.0915 on Gauntlet and 0.1582 on Forge. On Forge its dashboard widget showed no data and its backfill silently missed changes, two criticals that took its multiplier down to 0.41. On Gauntlet no rows reached its console, no amounts rendered and its approval flow couldn't finish, three criticals and a multiplier of 0.22. I read that as the base model, building a whole app alone in goose, failing at exactly the parts a person would use. That says nothing yet about T11: T11 hasn't been run on either board, and I won't guess where it would land.&lt;/p&gt;

&lt;p&gt;Then the data. Every training example T9 had written, 24,549 of them, was regenerated by the base model on the rented box in 13 minutes and filtered: 631 refused for knowledge the source didn't support, 43 for invented admin facts, 32 for naming a person, 26 for forum shape, plus 191 naming someone not in the prompt and 5 private-dictionary hits caught at home. A whole-mix name scan now fails the build if anything slips through. Run on T9's data, it catches 131 of the 220 Community samples.&lt;/p&gt;

&lt;p&gt;It trained on one 8x B200 Spot box at about $14.4 an hour, 7,157 steps in about 3 hours, with the name, fabrication, loop and knowledge checks running on every checkpoint on a spare card. The automatic stop fired once, at step 2147, on detector noise: a licence type, a role and an Atlassian product, not names. We fixed the detector, turned the automatic stop off and read every check by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  T11 had a Forge problem too
&lt;/h3&gt;

&lt;p&gt;Two real problems showed up while it trained. Asked "Are you Claude?" or "Are you GPT-4?", it said yes 3 of 8 times. And its Forge knowledge stuck at 53-63%, where T9 scores 90% at full precision.&lt;/p&gt;

&lt;p&gt;That second one is the same story as the top of this post, from the other side. Starting from the base model, T11's general coding held up fine (HumanEval 96.3 against T9's 95.7). Forge, it had to be taught.&lt;/p&gt;

&lt;p&gt;A 26-minute booster fixed the first problem and helped the second: 760 question-answer rows from Atlassian's own Forge documentation, plus Forge, identity and replay rows. Ten of the 30 Forge test questions were held out of the booster entirely, seeded before it ran, and it scored 80% on those ten. So the gain wasn't only memorised answers, though T9 got all ten.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F1c86dd1e4c85bbefea57d70862dcdda730af6af2-1600x896.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F1c86dd1e4c85bbefea57d70862dcdda730af6af2-1600x896.png" title="Illustration, generated locally with FLUX. Ten of the 30 Forge questions were kept off the study sheet. Before the late Forge fix, T11 got 8 of them right." alt="Cartoon illustration of a large blue robot marking a small green robot's test paper with a red pen while the small robot looks worried" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I made one mistake that cost a re-run, about $50 and 3 hours. The box finished and shut itself down with nothing exported: the booster's checkpoints hadn't been copied off it, and the automatic pick had refused every checkpoint on detector noise. Finding a new box took 72 minutes, with no Spot capacity in Stockholm, and we ended up in Virginia at $34.55 an hour. This time every checkpoint was copied the moment it was saved.&lt;/p&gt;

&lt;h3&gt;
  
  
  The loop that wasn't
&lt;/h3&gt;

&lt;p&gt;One Forge prompt looped on 3 of 8 samples from T11, and T9 hadn't looped on it in the one sample we had. That looked like a regression. I asked for a fix round first.&lt;/p&gt;

&lt;p&gt;Before training anything, the fix round measured that prompt 16 times on both models. T9 looped 2 of 16. T11 looped 1 of 16. The earlier comparison was one sample against eight. The fix itself, 43 steps on 15 self-written Forge apps, made it worse: 4 of 16, and Forge down to 70. By the rule written before the run, nothing was exported. That cost about $7 and taught the cheapest lesson of the fortnight: compare a looping prompt on the same number of samples for both models before you pay to fix it.&lt;/p&gt;

&lt;p&gt;The money for that day: I raised the cap to $1,100 and then to $1,250. Start to finish, the cap went $800, $450, $650, $800, $900, $1,000, $1,100, $1,250.&lt;/p&gt;

&lt;h3&gt;
  
  
  The late catch: T11 could barely build a Forge app
&lt;/h3&gt;

&lt;p&gt;T11 was chosen, measured and in its release gates on the Mac when the last of them ran. It asks for 35 Forge apps, 3 tries each, and checks every one with Forge's own manifest linter, its module allowlist and the TypeScript compiler. That gate had never been run on this round before release.&lt;/p&gt;

&lt;p&gt;On average over the three tries, T11 built a complete, valid Forge app for 9.7 of the 35 requests. T9 builds 30. It wrote manifests missing required fields, module keys and permission scopes that don't exist, and UI components that aren't in Forge's library.&lt;/p&gt;

&lt;p&gt;The cause was in our mix. T9's validated Forge app code had been diluted to about 4% of T11's 50,000 rows, where it was 19% of T9's, and three of T9's Forge-code sets were missing altogether. Being able to answer Forge questions isn't the same as being able to build a Forge app.&lt;/p&gt;

&lt;p&gt;I asked for the fix first. A 388-step booster from the chosen checkpoint on 2,127 validated Forge code rows, plus 22% replay, ran for about 1.5 hours on an 8x H100 box, because the H200 and B200 class had no capacity anywhere. Rows that taught any held-out test item, or matched a test brief, were removed first, and so were 206 rows containing client names. It cost about $50.&lt;/p&gt;

&lt;p&gt;The fixed model, checkpoint 7636, builds a complete Forge app for 27.3 of the 35 on average (T9 30) and a valid manifest for 29.3 (T9 31.3). Its goose agent defect rates came out lower than T9's again: no edit without a path, 0 against 1.7%, missing required arguments 0.4% against 1.6%, and repeated calls 2.3% against 4.1%. None of those differences is significant, so I don't claim them as gains. It paid for that elsewhere. On the box, Forge knowledge questions fell to 63 from 73 before the fix (T9 90), Atlassian multiple choice slipped to 76.4 from 78.4, and on the 2 held-out scenarios where it should ask the user first, it never asked. T9 asked in a quarter of its samples. I chose app building, and that's the model we published.&lt;/p&gt;

&lt;h3&gt;
  
  
  A new generation should now cost a fraction of the first
&lt;/h3&gt;

&lt;p&gt;The expensive part was everything before T11, about $880: T10 itself, and building and debugging the factory it ran on. With it built, I estimate a clean round on a new base model at one box for 5 to 6 hours, about $70 to $90 of rental, plus the release on the Mac. No such round has run yet.&lt;/p&gt;

&lt;p&gt;I've put the rules these two rounds paid for into the recipe. Copy every checkpoint as it's saved. Never let detector noise stop a run. Keep one box for every phase. Measure the starting model first. Put known fixes into the main mix. And compare a looping prompt on enough samples for both models.&lt;/p&gt;

&lt;h2&gt;
  
  
  T11 against the published v0.5: better at tool calls, level on Atlassian, behind on Forge
&lt;/h2&gt;

&lt;p&gt;T11 went public on Monday 5 October at 02:29 and 02:41 CEST, as an &lt;a href="https://huggingface.co/Mihai-LeanZero/Qwen3.8-27B-Atlassian-v2-goose-Q8-mlx" rel="noopener noreferrer"&gt;8-bit MLX build&lt;/a&gt; and a &lt;a href="https://huggingface.co/Mihai-LeanZero/Qwen3.8-27B-Atlassian-v2-goose-lora-peft" rel="noopener noreferrer"&gt;PEFT LoRA adapter&lt;/a&gt;. Each was uploaded private, downloaded back, checked byte for byte, probed for private data on the downloaded files (309 probes, 0 hits), and only then made public. The 6-bit and 4-bit builds pass their quality gates but stopped at the privacy scan: the 6-bit conversion re-saved the tokenizer file and the scan found a dictionary term in it. They're held, not uploaded. The model cards already list them, and those two links won't open until they're public.&lt;/p&gt;

&lt;p&gt;Here are the numbers I'd quote, from the model card. The rule since T10 has been that the release's fabrication and privacy checks are hard blockers, and any other failed gate can only ship as a named waiver on the card. Loops were a blocker for T10. For T11 I let them through as a waiver. In all, eight of T11's release gates failed. All eight shipped as named waivers on the card. They are loops, Forge app building, Atlassian knowledge against T9 (on the Forge questions), voice, one general-skills leg, the ask-first goose scenarios, 8-bit agreement on long agent contexts, and the LM Studio load test.&lt;/p&gt;

&lt;p&gt;People and privacy: no invented names in ticket replies, where T9 greets an invented person in 18 to 20 of 20. On the same 309 privacy probes T10 was run through, 0 hits. T9 produces 57 (60 before a pre-registered rule for spelling variants), and T9's are the invented ticket keys and URLs described above, not memorised data. One goose output did use my name where nothing in the task called for it, and that's on the card too.&lt;/p&gt;

&lt;p&gt;Fabrication: one invented date in 320 answers about named organisations, on the full-precision model. That one comes from the full-precision checks on the training machine, and it's on the card. The base model invented 28 details on the same test.&lt;/p&gt;

&lt;p&gt;Loops: not clean. 2 loops in the 160-answer battery against T9's 1, every leg at or below the base model's, and it shipped under a waiver, under my decision to go ahead. With T10, the same 2 against 1 was part of why it stopped. The difference is scale: T10 invented people and 49 company details, and T11 invented no people and one date in 320 organisation answers.&lt;/p&gt;

&lt;p&gt;Atlassian knowledge, on the shipped 8-bit builds: multiple choice 74.4 against 71.9, a little higher but not a significant difference, so I call it level. Forge knowledge questions: 19 of 30 against 26, which is significantly worse. Complete Forge apps: 27.3 against 30 of 35, on average over three tries. That's the honest shape of it: in the vicinity of T9 on Atlassian knowledge, worse at Forge.&lt;/p&gt;

&lt;p&gt;Tool calling is where it's better, measured on the full-precision model on the training machine. On BFCL, a public function-calling benchmark, 91.8 against 85.7, and declining tools it shouldn't call 85.8 against 76.7, both significant. On the 294 held-out goose scenarios, every measure came out non-inferior or better except one: in the 2 scenarios where the right move is to ask the user first, it never asked. T9 asked in a quarter of its samples. That gate shipped as a waiver too.&lt;/p&gt;

&lt;p&gt;General ability, also full precision: MMLU 84.3 against 83.0, IFEval, an instruction-following test, 81.5 against 82.4, and HumanEval 96.3 against 95.7. The general-skills gate still failed on one leg, a thinking-format test, 50.3 against 52.3, and that's another waiver.&lt;/p&gt;

&lt;p&gt;Its voice is much closer to mine than T9's on our style measure, a combined distance of 0.65 against T9's 3.07. It still misses my range on four features. It asks questions in 15% of replies where I do in 29 to 46%, greets in 31% where I do in 35 to 54%, signs off in 6% where I almost never do, and uses exclamations in 6.2% where my top is 6.1%. T9 signed off in 58%. The voice gate counts those four misses, so it's a waiver too.&lt;/p&gt;

&lt;p&gt;One more limit. The 8-bit build matches full precision on general text, but on goose agent contexts up to 16,384 tokens long it drifts: a KL divergence of 0.400 and top-1 agreement of 89.9%, against a gate of 0.067 and 90%. When we diagnosed the same drift on the earlier checkpoint, the rows that drifted also drifted between MLX and the PyTorch reference at full precision, so the framework is part of it. The PEFT adapter on the bf16 base is the reference. Every waiver, this one included, is on the model card, and I'd read it before you download anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every failure, what it cost and what fixed it
&lt;/h2&gt;

&lt;p&gt;I've told the big ones already. Here's the whole list for both rounds, 30 September to 5 October, in order. Where we measured what a failure cost, in money or time, it's there.&lt;/p&gt;

&lt;p&gt;T10, continuing from T9:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The plan underestimated cost by 6 to 10 times. It said $100 to $160. Every stage needed 8-GPU boxes for 64k-token rows, and Spot capacity for those was scarce. I moved the cap seven times across both rounds, five of them during T10.&lt;/li&gt;
&lt;li&gt;Hardware assumptions. A single H100 or RTX PRO 6000 couldn't train 64k rows, and the Mac tops out at 12,288 tokens. Fix: one fixed 65,536-token length, 8x H200 or B200 boxes only.&lt;/li&gt;
&lt;li&gt;Capacity. Two hours waiting on 1 October, seven empty 15-minute walks across regions on 3 October, and a launch hand-over bug that lost one box and started a duplicate, $22.60 between them. Fix: the launcher refuses instead of walking, and a grab loop now catches boxes.&lt;/li&gt;
&lt;li&gt;All 732 rows from the first factory run came from 2 templates, because briefs ran in id order and the spend stop hit after 65 of 1,483. The re-run for diversity cost $57.&lt;/li&gt;
&lt;li&gt;The privacy treatment of real sessions was too slow, and the session bundle failed its audit. T10 trained on voice data only.&lt;/li&gt;
&lt;li&gt;My name in the training targets: 222 of 651 contrast replies T9 had written named me or signed as me. Fix: a hard rule that no target may name me.&lt;/li&gt;
&lt;li&gt;The relaunch chose nothing on 2 October. Every candidate named me or greeted an invented person in most replies. Causes: the persona line named me, the privacy treatment had swapped real names for invented ones, and T9's habit came through its adapter.&lt;/li&gt;
&lt;li&gt;A filter that hadn't been pushed was missing on the box, and two parts failed after we'd rented it. Fix: the box gets only what's pushed and tested.&lt;/li&gt;
&lt;li&gt;A guard stop at step 1012 on numeric-only breaches, then a resume that crashed on restore because every rank loaded the optimizer onto GPU 0. Cost: a box, $8.31. Fix: &lt;code&gt;map_location=cpu&lt;/code&gt;, numeric stops off, every guard read by a person.&lt;/li&gt;
&lt;li&gt;A knowledge drain, found late. Later checkpoints lost 3.5 to 8 points of Atlassian knowledge, and selection had never measured knowledge. Fix: knowledge on every checkpoint in selection. My ruling: a small drop is fine, 20 to 30% isn't.&lt;/li&gt;
&lt;li&gt;Invented people in every checkpoint, 17 to 47 of 160 replies. Eight short corrective rounds got one version to 0, and I approved it.&lt;/li&gt;
&lt;li&gt;The release gates stopped it anyway: invented forum handles, 49 fabricated company specifics, 2 loops. Root cause: 84 of T9's 220 Community answers greet the asker by handle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;T11, a clean model from the base:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detector noise stopped training at step 2147. A licence type, a role and an Atlassian product were read as invented people. Fix: a stoplist for the detector, numeric stops off, every guard read by a person.&lt;/li&gt;
&lt;li&gt;During the manual resume we killed the wrong process, the guard's server instead of the evaluator's. About 20 minutes of re-measurement.&lt;/li&gt;
&lt;li&gt;The box shut down with nothing exported. The booster's checkpoints hadn't been copied to storage, and the automatic pick refused every one on detector noise. About $50 and 3 hours to re-run. Fix: every checkpoint copied the moment it's saved, and the pick made by the coordinator, not automatically.&lt;/li&gt;
&lt;li&gt;A misread clock made training look slow. Fix: always check the real clock.&lt;/li&gt;
&lt;li&gt;Capacity again. 72 minutes to find the re-run's box, and at one point nothing in six regions, Spot or on-demand. A grab loop caught one.&lt;/li&gt;
&lt;li&gt;The Mac's GPU timed out merging the adapter. Fix: merge on the CPU, about 2 minutes.&lt;/li&gt;
&lt;li&gt;LM Studio on the release machine indexes no local models at all, so the LM Studio load gate could never be tested. Waived, on the card.&lt;/li&gt;
&lt;li&gt;An unfair comparison. One T9 sample against eight T11 samples made "T9 never loops on this prompt" look like a fact. The fix round I asked for, about $7, made it worse. Measured fairly, T9 looped 2 of 16 and T11 1 of 16. Fix: equal samples first.&lt;/li&gt;
&lt;li&gt;The 8-bit build drifts from full precision on long agent contexts. Diagnosed on the earlier checkpoint as largely the framework, waived and stated on the card.&lt;/li&gt;
&lt;li&gt;Forge app building collapsed, 9.7 of 35 against T9's 30. Cause: validated Forge app code diluted to about 4% of the mix and three Forge-code sets missing. Nobody saw it until the gate first ran at release. The fix round I chose cost about $50 and got it to 27.3, and it cost Forge question answering.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The release night, 4 to 5 October:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The second release shared the first one's private output folder, so three checks, manners, privacy and voice, silently reused the earlier model's answers. We caught it because they finished in seconds instead of minutes, and re-ran them on the right model.&lt;/li&gt;
&lt;li&gt;More detector false positives: a file format, a role and a code annotation read as people. Added to the stoplist.&lt;/li&gt;
&lt;li&gt;The card generator only knew "main run plus booster", so the Forge-fix stage was written into the card by hand and checked line by line. A card review found nine defects first. The biggest was a false claim that the 8-bit build matches full precision.&lt;/li&gt;
&lt;li&gt;Hugging Face writes its own &lt;code&gt;.gitattributes&lt;/code&gt;, which our upload check read as tampering. Excluded, and every model file matched.&lt;/li&gt;
&lt;li&gt;A stale figure from the earlier checkpoint stayed in one card note after publishing. Caught by a review of the site page and corrected on the live cards.&lt;/li&gt;
&lt;li&gt;The 6-bit and 4-bit uploads stopped at the privacy scan, as above. Nothing was uploaded.&lt;/li&gt;
&lt;li&gt;Names leaked twice into the fact sheet this article was written from, forum handles in one file and names from model outputs in another. Removed, and every file now gets the name scan before it's used.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What I think worked: the release gates, which caught 12, 22 and 28 before anything went public, even where I then chose to ship under a waiver. Testing the starting model before training, which showed the name habit was T9's, not Qwen's. Training from the base instead of patching. Held-out test items for the booster. The private, then probe, then public publishing order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was renting GPUs worth it? For this one model, no
&lt;/h2&gt;

&lt;p&gt;I asked for a blunt answer to that, and here it is.&lt;/p&gt;

&lt;p&gt;The whole effort, T10 and T11, cost $1,051.87 before tax, $1,269.50 with AWS's VAT. GPU boxes were nearly all of it. The plan's first estimate had said $100 to $160.&lt;/p&gt;

&lt;p&gt;What did it buy, T11 against T9? Atlassian knowledge in the vicinity of T9: a little higher on multiple choice, not significantly, and worse on Forge. Tool calling better, on BFCL (91.8 against 85.7) and on declining tools it shouldn't call (85.8 against 76.7). And trust: no invented people, 0 hits on the 309 privacy probes where T9 produces 57 invented keys and URLs.&lt;/p&gt;

&lt;p&gt;T9 trained on our own Mac Studio. 2,600 steps, 5.8M tokens, about 23 hours, for the price of the electricity.&lt;/p&gt;

&lt;p&gt;So for this one model, judged on its gains alone, renting wasn't worth it. Two things soften it.&lt;/p&gt;

&lt;p&gt;First, most of the money went on T10 and on building and debugging the rental pipeline, not on the model we published. T11 itself cost about $160, the late Forge fix included. The next round on a new base model is now estimated at $70 to $90 of rental, plus the release on the Mac. Whether the rest was worth it depends on whether there's a next round.&lt;/p&gt;

&lt;p&gt;Second, much of what a user will notice didn't need rented GPUs at all. No invented people, no invented ticket keys, the voice: that came from cleaning the data and starting from the original Qwen model instead of T9. The identity fix, for a habit T11 picked up in its own training, came from 143 short identity rows in the booster. Those rows are short too. The Mac could have trained them in a few days.&lt;/p&gt;

&lt;p&gt;What I haven't measured, and should before renting again, is which rows bought the tool-calling gain. T11's mix had 527 goose rows out of 50,095. The BFCL gain might come from short general rows the Mac could train. The same mix minus the long goose rows, trained at home, would answer that for the cost of a few days of a machine we already own.&lt;/p&gt;

&lt;p&gt;The rule for next time is in our recipe now. Train everything that fits on the Mac first, measure it, and rent a box only for what the Mac can't do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opus or Sol for Forge; Sol Pro for a payments app
&lt;/h2&gt;

&lt;p&gt;If you're choosing a model for general app work, GPT-6.1 Sol Pro is the one that cleared every bar on Gauntlet. The next four finished within 0.006 of each other under the same cap. They didn't build the same app. Sonnet earned the most before the cap, Sol wrote about 530 tokens a call to Sonnet's 6,100, and the payment Opus's approval flow created never showed up in its table. Same score, different apps.&lt;/p&gt;

&lt;p&gt;If you're choosing one for Forge, Opus 5.5 and GPT-6.1 Sol are the two I'd trust, with Sol Pro close behind. Sonnet 5.5 built a good ledger and then lost almost half its score at a button, a modal and an LLM call. That's exactly where a Forge app meets a real person, which is why the scorer prices it the way it does.&lt;/p&gt;

&lt;p&gt;And if you're fine-tuning your own model: measure the starting point first, keep every checkpoint off the box the moment it's saved, run every release gate before you call a model chosen, and assume the parent's manners come along for free.&lt;/p&gt;

&lt;p&gt;One run per model. Bills are a lower bound. The boards move, and they'll have moved again by the time you read this, so open the &lt;a href="https://leanzero.net/agentic-benchmarks/forge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Forge board&lt;/a&gt; and the &lt;a href="https://leanzero.net/agentic-benchmarks?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Gauntlet board&lt;/a&gt; for the current numbers, and click any run for its every check.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/blog/claude-sonnet-5-5-vs-opus-5-5?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>forge</category>
      <category>atlassian</category>
      <category>localai</category>
    </item>
    <item>
      <title>Atlassian Team '26 Europe: dates, agenda, keynote and livestream times</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Mon, 05 Oct 2026 05:00:50 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/atlassian-team-26-europe-dates-agenda-keynote-7i0</link>
      <guid>https://dev.to/mihai_leanzero/atlassian-team-26-europe-dates-agenda-keynote-7i0</guid>
      <description>&lt;h1&gt;
  
  
  Atlassian Team '26 Europe: dates, agenda, keynote and livestream times
&lt;/h1&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Atlassian Team '26 Europe runs Tuesday 6 to Thursday 8 October 2026 at RAI Amsterdam. Monday 5 October is Day 0, with the partner-only Partner Accelerate, which is sold out.&lt;/li&gt;
&lt;li&gt;Founder Keynote: Wednesday 7 October, 09:00-10:15 CEST, which is 08:00-09:15 in the UK. Atlassian's FAQ says you do not need to register to watch it.&lt;/li&gt;
&lt;li&gt;Closing Keynote: Thursday 8 October, 15:00-16:15 CEST (14:00-15:15 UK). Its session page is tagged On demand only, so don't count on watching it live.&lt;/li&gt;
&lt;li&gt;The Digital Pass is free. Atlassian's pages give two different livestream start times (08:30 and 08:45 CEST) and two on-demand dates (11 and 12 October).&lt;/li&gt;
&lt;li&gt;Anaheim in May shipped Agents in Jira and the new Rovo Studio experience as generally available, and put the Teamwork Graph CLI and Teamwork Graph tools in Rovo MCP Server into open beta.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Atlassian Team '26 Europe runs Tuesday 6 to Thursday 8 October 2026 at RAI Amsterdam, and today, Monday 5 October, is Day 0. The one slot to put in your calendar is the Founder Keynote, Wednesday 7 October at 09:00 CEST (08:00 in the UK). Atlassian's FAQ says you can watch it without registering. Atlassian's own pages disagree with each other in three places, so I've put both versions side by side below instead of guessing.&lt;/p&gt;

&lt;p&gt;Gabriela and I are going. No booth, no talk; &lt;a href="https://leanzero.net/blog/leanzero-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;I wrote in September why we're going anyway&lt;/a&gt;. This page is the hub for our seven-part series, which runs until Monday 12 October, and every part gets linked at the bottom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atlassian Team '26 Europe dates and location: RAI Amsterdam, 6-8 October
&lt;/h2&gt;

&lt;p&gt;Atlassian's &lt;a href="https://events.atlassian.com/teameurope/faq" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt; puts it plainly: "Team '26 Europe will take place 6-8 October 2026 at the RAI Convention Center in Amsterdam, Netherlands." Over 100 sessions, according to the same page. The themes run from AI, collaboration and service management to cloud migration.&lt;/p&gt;

&lt;p&gt;Monday is &lt;a href="https://leanzero.net/blog/atlassian-partner-accelerate-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Partner Accelerate, a partner-only day&lt;/a&gt; from 9:30 to 17:30 at the same venue. It's sold out. Atlassian's &lt;a href="https://events.atlassian.com/teameurope/partner-accelerate" rel="noopener noreferrer"&gt;Partner Accelerate page&lt;/a&gt; is careful to add that "It is only Partner Accelerate that is sold out", so partners can still register for the main event. (The FAQ calls Monday Day 1, the agenda calls it Day 0. It's Monday either way.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Team '26 Europe agenda and schedule by day
&lt;/h2&gt;

&lt;p&gt;This is the &lt;a href="https://events.atlassian.com/teameurope/agenda" rel="noopener noreferrer"&gt;agenda&lt;/a&gt; as Atlassian publishes it. The agenda page prints bare times. The FAQ and the event home page say CEST, so read everything here as Amsterdam time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tuesday 6 October: breakout and learning sessions 10:00-18:00, the Expo 13:00-18:00, Welcome Reception 16:00-18:00.&lt;/li&gt;
&lt;li&gt;Wednesday 7 October: the Expo 08:00-18:00, Founder Keynote 09:00-10:15 on the agenda (the session page now says 10:30), breakouts 11:00-16:00, reception on the Expo floor 16:00-18:00.&lt;/li&gt;
&lt;li&gt;Thursday 8 October: the Expo 08:00-15:00, breakouts 08:30-14:30, Closing Keynote 15:00-16:15, then BASH 16:30-19:30 at StrandZuid, five minutes' walk from the RAI.&lt;/li&gt;
&lt;li&gt;Monday 12 October: the on-demand library (more on that date below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things from the FAQ will matter on the day. Adding a session to your agenda reserves a spot in your planner, not a seat. Learning sessions are the exception, and you bring your own laptop and charger for those. Unclaimed seats are released 10 minutes before the start. If a session matters to you, be early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Team '26 Europe keynote times in CEST and UK time
&lt;/h2&gt;

&lt;p&gt;The UK is an hour behind Amsterdam and Romania an hour ahead. All three are on summer time this week (the clocks go back together on 25 October), so the table holds for the whole event. I added Romanian time because that's where we're based.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keynote&lt;/th&gt;
&lt;th&gt;CEST (Amsterdam)&lt;/th&gt;
&lt;th&gt;UK (BST)&lt;/th&gt;
&lt;th&gt;Bucharest (EEST)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Founder Keynote, Wed 7 Oct&lt;/td&gt;
&lt;td&gt;09:00-10:15&lt;/td&gt;
&lt;td&gt;08:00-09:15&lt;/td&gt;
&lt;td&gt;10:00-11:15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closing Keynote, Thu 8 Oct&lt;/td&gt;
&lt;td&gt;15:00-16:15&lt;/td&gt;
&lt;td&gt;14:00-15:15&lt;/td&gt;
&lt;td&gt;16:00-17:15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Founder Keynote: Your business knows better
&lt;/h3&gt;

&lt;p&gt;Main Stage, Wednesday. The &lt;a href="https://events.atlassian.com/teameurope/session/4310783/founder-keynote-your-business-knows-better" rel="noopener noreferrer"&gt;session page&lt;/a&gt; lists Mike Cannon-Brookes, Sherif Mansour, Tamar Yehoshua and Taroon Mandhana. On 5 October it also listed Josh Miller of The Browser Company. It's tagged Livestream and On demand. This is where the announcements land, if there are any.&lt;/p&gt;

&lt;h3&gt;
  
  
  Closing Keynote: What becomes possible
&lt;/h3&gt;

&lt;p&gt;Main Stage, Thursday afternoon. The &lt;a href="https://events.atlassian.com/teameurope/session/4308516/closing-keynote-what-becomes-possible" rel="noopener noreferrer"&gt;session page&lt;/a&gt; lists Hannah Waddingham, Brian Duffy, Amit Puntambekar (CTO, Reddit), Hilary Lanham (Lloyds Banking Group) and Mike Cannon-Brookes. It describes two parts. First, a technical customer panel on connected context as "the foundation for AI at scale". Then a fireside chat with Hannah Waddingham. The page is tagged On demand, and not Livestream.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to watch Team '26 Europe online: livestream and on demand
&lt;/h2&gt;

&lt;p&gt;The Digital Pass is free. The &lt;a href="https://events.atlassian.com/teameurope/" rel="noopener noreferrer"&gt;event home page&lt;/a&gt; says "Sign up for free", and the sign-up form is on the &lt;a href="https://events.atlassian.com/teameurope-digital/register" rel="noopener noreferrer"&gt;digital registration page&lt;/a&gt;. In September I couldn't confirm that. Now it's confirmed.&lt;/p&gt;

&lt;p&gt;You don't even need the pass for the Founder Keynote. The FAQ: "You do not need to register to access the Founder Keynote. Registration is required to access select Keynotes, Solution Keynotes, and all other on-demand content."&lt;/p&gt;

&lt;p&gt;Now the three disagreements.&lt;/p&gt;

&lt;p&gt;Livestream start. The FAQ says it begins at 8:30 CEST on Wednesday and Thursday. The home page and the &lt;a href="https://events.atlassian.com/teameurope-digital/sessions" rel="noopener noreferrer"&gt;online sessions page&lt;/a&gt; show the programme from 8:45 to 13:00 CEST. Join the live stream at 8:30 and you miss nothing.&lt;/p&gt;

&lt;p&gt;The Closing Keynote. The home page lists Thursday's stream as "AI sessions + Closing Keynote 8:45 - 13:00 CEST". But the keynote starts at 15:00, two hours after that window closes. The online sessions page has no keynote in Thursday's list, and the keynote's own page says On demand only. My read: don't plan on watching it live.&lt;/p&gt;

&lt;p&gt;On demand. The FAQ says recorded sessions are available "starting 11 Oct". The agenda and the home page say 12 October. Only select sessions are recorded, but the FAQ promises AI-generated summaries of all in-person sessions, and captions in English, Spanish, French, Japanese and German.&lt;/p&gt;

&lt;h2&gt;
  
  
  Team '26 Anaheim announcements: what to expect in Amsterdam
&lt;/h2&gt;

&lt;p&gt;Team '26 in Anaheim ran 5-7 May. Here is what Atlassian announced there, with the availability exactly as its &lt;a href="https://www.nasdaq.com/press-release/atlassian-opens-its-teamwork-graph-power-agentic-work-across-enterprise-2026-05-06" rel="noopener noreferrer"&gt;press release&lt;/a&gt; and Mike Cannon-Brookes' &lt;a href="https://www.atlassian.com/blog/company-news/founder-update-team-26" rel="noopener noreferrer"&gt;founder update&lt;/a&gt; state it.&lt;/p&gt;

&lt;p&gt;Two things went generally available that matter most to anyone who runs Jira. Agents in Jira lets teams assign work to Rovo and third-party agents. The other is the new Rovo Studio experience. DX AI Experience went generally available too. The Teamwork Graph CLI and Teamwork Graph tools in Rovo MCP Server went into open beta. Remix with Rovo is in beta, and Confluence slides are "coming soon in beta". Max mode in Rovo Chat is coming soon. Product Collection and Code Intelligence in Rovo are in early access.&lt;/p&gt;

&lt;p&gt;Dia is the odd one. Neither the press release nor the founder update labels it generally available. Atlassian says it is "now ready for teams of all shapes and sizes to try", with a closed beta for advanced enterprise features, including a Guard integration "that will be available soon".&lt;/p&gt;

&lt;p&gt;What Amsterdam adds, I don't know. Nobody outside Atlassian does until Wednesday at 09:00. What I'll check first is whether the May items marked beta or coming soon have moved. The Teamwork Graph is clearly on the bill either way. Two of the livestreamed sessions have it in the title: "Connected Context, Governed Action: Scaling Enterprise AI with the Teamwork Graph and Rovo" on Wednesday and "Maximising your context layer with the Teamwork Graph" on Thursday.&lt;/p&gt;

&lt;h2&gt;
  
  
  What LeanZero will look at in Amsterdam
&lt;/h2&gt;

&lt;p&gt;We build Forge apps and run Atlassian migrations, so this series reads Team '26 Europe from those two seats. Agents in Jira matters to us because CogniRunner already puts AI into Jira workflows. Gabriela compared &lt;a href="https://leanzero.net/blog/rovo-agent-permissions-cognirunner-approvals?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Rovo agent permissions with CogniRunner approvals&lt;/a&gt; on 4 October. I'd rather redo that comparison against whatever ships than guess. The MCP side matters because tool definitions are not free. Across ten real MCP servers we measured, &lt;a href="https://leanzero.net/blog/mcp-tool-definitions-context-cost?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;145 tool definitions came to 40,784 tokens&lt;/a&gt;, sent with every request. The schemas drove the cost. Not the tool count. Rovo MCP Server wasn't one of the ten, so how heavy the Teamwork Graph tools are is still an open question.&lt;/p&gt;

&lt;p&gt;For a Forge developer, the sessions worth marking on Atlassian's &lt;a href="https://events.atlassian.com/teameurope/recommended-agendas?recommended=developer" rel="noopener noreferrer"&gt;recommended agendas page&lt;/a&gt; are three. One is already full. "Give coding agents the context behind the code with the Teamwork Graph" is on Tuesday at 13:30, repeated Wednesday at 10:45. "Operationalize Rovo agents with subagents, evaluations, and autonomy" is a hands-on Learning session, Tuesday at 11:00 and again Wednesday at 15:00. Both runs are already marked full. "Your Atlassian context, in Claude, ChatGPT, and any AI agent" is Wednesday at 10:45, repeated Thursday. Spot the clash? I would take the Tuesday run of the first one. The "Getting Started with Forge" workshop on Tuesday morning is full too.&lt;/p&gt;

&lt;p&gt;Tomorrow is Day 1: breakouts from 10:00, the Expo from 13:00. Part 2 goes out tomorrow morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every article in this series
&lt;/h2&gt;

&lt;p&gt;I add each part here the day it goes live.&lt;/p&gt;

&lt;p&gt;Part 1: Atlassian Team '26 Europe: dates, agenda, keynote and livestream times (this page).&lt;/p&gt;

&lt;p&gt;Part 2: &lt;a href="https://leanzero.net/blog/atlassian-partner-accelerate-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Atlassian Partner Accelerate at Team '26 Europe: Forge, revenue share and who we met&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Part 3: &lt;a href="https://leanzero.net/blog/team-26-europe-amsterdam-jira-sentiment-analysis?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Team '26 Europe in Amsterdam: Jira Service Management sentiment analysis in triage queues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Part 4: &lt;a href="https://leanzero.net/blog/atlassian-team-26-europe-announcements?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Atlassian Team '26 Europe announcements: AMP, MCP Server and Rovo Work, explained&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/blog/atlassian-team-26-europe?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>atlassian</category>
      <category>rovo</category>
      <category>forge</category>
      <category>events</category>
    </item>
    <item>
      <title>Rovo Agent Permissions vs CogniRunner Approvals</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Sun, 04 Oct 2026 09:06:36 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/rovo-agent-permissions-vs-cognirunner-approvals-e1b</link>
      <guid>https://dev.to/mihai_leanzero/rovo-agent-permissions-vs-cognirunner-approvals-e1b</guid>
      <description>&lt;p&gt;In November 2025 someone asked on the Atlassian Community whether Rovo agent permissions could be set by group, and that question has been viewed &lt;a href="https://community.atlassian.com/forums/Jira-Service-Management/Is-it-possible-to-set-Rovo-agent-access-permissions-by-group/qaq-p/3147847" rel="noopener noreferrer"&gt;1,920 times&lt;/a&gt; since. The short answer is still no. You can limit who creates agents by group, but who can use an agent is a list of individual people, and that's the bit admins keep tripping over.&lt;/p&gt;

&lt;p&gt;I read Atlassian's governance pages, the two announcement posts behind them (6,736 and 6,583 views) and the open tickets on 2 October 2026, so everything below is as of that day. First I'll walk you through what Rovo lets an admin control, and what it writes down. Then I'll show you the other half of the problem, the one permissions don't solve: what happens when an agent wants to delete something. That's where Mihai's work on CogniRunner comes in, and I'll be honest about what it doesn't do as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rovo agent permissions and governance: what an admin can set today
&lt;/h2&gt;

&lt;p&gt;There are three separate switches, and they're easy to blur together.&lt;/p&gt;

&lt;p&gt;Creating agents is open to everyone by default. Atlassian's &lt;a href="https://support.atlassian.com/rovo/docs/rovo-agent-permissions-and-governance/" rel="noopener noreferrer"&gt;Rovo agent permissions and governance&lt;/a&gt; page lists the three options a Studio admin has: "All users", "Selected groups", which "limits agent creation to up to 10 user groups", and "No users", which leaves creation to you and the other organization admins.&lt;/p&gt;

&lt;p&gt;One thing to watch if you go for groups. There's an open bug, ROVO-1228: a Studio app admin who isn't also an org admin can't add groups to that create permission. The group list never loads, and a group you type in shows Saved but is gone after a refresh. It was filed on 11 September and sits in Atlassian's short-term backlog. The workaround is to ask an organization admin to add the groups. If your groups won't stick, it's not you.&lt;/p&gt;

&lt;p&gt;Editing an agent is per agent. The person who builds it can add Editors, who can edit, and Managers, who can also add other editors and delete the agent. Heads up, the docs and the product announcement don't quite agree here. The governance page still says you give each person "either editor or manager", while the February 2026 announcement says the roles are now "managers, editors, users". If your screen shows three roles, that's why.&lt;/p&gt;

&lt;p&gt;Using an agent is the one people ask about. It's also the loosest. "When you create an agent, it becomes available for everyone to see and use by default." You can restrict it, and then "agents with restricted access are only visible by the people you add". Everyone else won't see it in the directory or as an option for automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who can use a Rovo agent: individual users, not groups
&lt;/h2&gt;

&lt;p&gt;Here's the sentence that answers the Community thread, straight from the governance page: "Restricting agent access to specific groups or teams is currently not supported. You will need to add users individually."&lt;/p&gt;

&lt;p&gt;Jensen Fleming from Atlassian said the same under the &lt;a href="https://community.atlassian.com/forums/Atlassian-AI-Rovo-articles/Announcing-User-Permissions-for-Rovo-Agents-Custom-Access-Your/ba-p/3196928" rel="noopener noreferrer"&gt;user permissions announcement&lt;/a&gt; in February: "currently only users". Groups "added a ton of dev complexity", so they shipped without them. The request to add groups is &lt;a href="https://jira.atlassian.com/browse/ROVO-875" rel="noopener noreferrer"&gt;ROVO-875&lt;/a&gt;. It's at Gathering Interest with 28 votes, so if you need it, go vote.&lt;/p&gt;

&lt;p&gt;Groups do work for one thing, and that's creation. The August 2025 &lt;a href="https://community.atlassian.com/forums/Atlassian-AI-Rovo-articles/IT-S-HERE-Rovo-Agent-Governance-Create-Permissions/ba-p/3095332" rel="noopener noreferrer"&gt;governance announcement&lt;/a&gt; is where that arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent acts as you, unless it has its own account
&lt;/h2&gt;

&lt;p&gt;Permissions on the agent decide who gets to talk to it. What the agent can then reach is a different question, and Atlassian answers it with identity.&lt;/p&gt;

&lt;p&gt;By default an agent uses the account of whoever is using it. The governance page puts it plainly: "If the user can't comment, the agent can't comment." The other option, set up in &lt;a href="https://support.atlassian.com/studio/docs/configure-a-rovo-agents-identity/" rel="noopener noreferrer"&gt;Configure a Rovo agent's identity&lt;/a&gt;, is the Agent's account. Then organization admins decide which apps it can reach, space admins and end users decide which spaces and pages, and its changes "appear under the agent's name making it easier to track in audit logs".&lt;/p&gt;

&lt;p&gt;That last part matters more than it sounds. On a user's account, an automated agent's changes are logged as the person who set up the automation. And if an admin never sets up an Agent's account, that's what you get.&lt;/p&gt;

&lt;p&gt;You can also narrow an agent through its instructions and the tools you give it. Just don't lean on the instructions. Atlassian's &lt;a href="https://support.atlassian.com/studio/docs/best-practices-to-automate-agents-safely/" rel="noopener noreferrer"&gt;best practices page&lt;/a&gt; says it twice: "Guardrail instructions are suggestions. There is no guarantee that the agent will follow them."&lt;/p&gt;

&lt;h2&gt;
  
  
  What Rovo logs when an agent acts
&lt;/h2&gt;

&lt;p&gt;People sometimes assume there's no trail at all. There is, in three places, and which ones you get depends on your plan.&lt;/p&gt;

&lt;p&gt;The first is the work item itself. Atlassian's guardrails guide says agent actions land "in the work item history alongside human activity", under the user's name or the agent's, depending on the identity above.&lt;/p&gt;

&lt;p&gt;The second is the organization audit log. Atlassian's list of audit log activities includes "Created Rovo agent", "Updated Rovo agent", "Deleted Rovo agent" and "Started chat with Rovo agent", plus "Invoked tool" for Rovo MCP. Atlassian files all four under Rovo user actions. When Rovo activities first arrived in the audit log in April 2025, the note said user actions are for Guard Premium. Admin actions, like connectors and bookmarks, are for Guard Standard and up.&lt;/p&gt;

&lt;p&gt;The third is newer. Atlassian's August 2026 update announced it: if your organization has Atlassian Guard Premium, "every time someone invokes an agent, an "Invoked Rovo agent" event is captured" under Insights, Audit Log. It records who invoked it, from where (Rovo Chat, Slack, Teams), what tools it used and what permissions it ran under. That's from Atlassian's &lt;a href="https://community.atlassian.com/forums/discussion/3271694/whats-new-in-rovo-agents-your-bi-monthly-update" rel="noopener noreferrer"&gt;bi-monthly Rovo agents update&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the honest summary? The work item history is always there. The agent events in the audit log, including the per-invocation one, are Guard Premium.&lt;/p&gt;

&lt;h2&gt;
  
  
  Human in the loop for AI agents in Jira: who approves the delete
&lt;/h2&gt;

&lt;p&gt;So who says yes before an agent changes something?&lt;/p&gt;

&lt;p&gt;In chat, the person using it. Atlassian's page on agent tools says the agent "will respond asking for confirmation before executing consequential tools that may mutate data across systems". The best practices page goes further: in interactive use "there's always a human in the loop to review and approve an action before the agent does anything". The same tools page also caps bulk actions in Jira at 20 work items at a time.&lt;/p&gt;

&lt;p&gt;In an automation, nobody. "The agent acts autonomously and will perform actions without a user confirming every task." You can limit the Use agent step to read actions only, and admins can stop agents acting in automations altogether.&lt;/p&gt;

&lt;p&gt;You'll find one Atlassian page that says the opposite, so let me save you the confusion. The tools page still says an agent in an automation "cannot use its own tools" and can only return text. The automation page says an agent there can "take actions directly". And Atlassian's August 2026 update, the newest of them, says agents in automation rules "can execute these actions directly" now. No stopping to ask. Plan for the agent acting.&lt;/p&gt;

&lt;p&gt;Agents on work items are their own case. You can assign an agent to a work item, or attach one to a workflow transition (that needs a space admin with Edit workflows). On a transition, "the agent is triggered when any team member moves a work item to the allocated status." And Atlassian's own guide is clear that "Assigning the agent runs it. It does not yet put a human in the loop, that is the checkpoint you add next."&lt;/p&gt;

&lt;p&gt;For anything hard to undo, that guide to &lt;a href="https://www.atlassian.com/software/jira/guides/agentic-engineering/human-in-the-loop" rel="noopener noreferrer"&gt;human-in-the-loop patterns for AI agents in Jira&lt;/a&gt; points you at the workflow: "approval is a workflow approval step that holds a transition until a named person signs off". It also says "Native approvals are available on Premium and Enterprise plans."&lt;/p&gt;

&lt;p&gt;That's a good pattern, and I'd use it. But notice what it gates. It holds a transition. It doesn't hold a single API call that an agent decided to make halfway through its work. And the person confirming in chat is whoever is talking to the agent. They can only confirm what their own permissions allow, but they're the requester, not a named approver.&lt;/p&gt;

&lt;p&gt;That's the gap CogniRunner's agents were built around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Learn, Shadow, Ask first, Live: a CogniRunner agent starts with no write access
&lt;/h2&gt;

&lt;p&gt;CogniRunner is our Jira app for AI workflow rules, and since Marketplace version 5.0.0 on 30 September 2026 it also has agents, which it calls virtual administrators. They work a queue on a schedule: a JQL filter in a service desk, say. The version on Marketplace on 4 October is 6.1.0, from 3 October. It fixed rules, tests and AI providers and changed nothing this article says about agents. The screenshots below were taken on 6.0.0, on 2 October.&lt;/p&gt;

&lt;p&gt;Every agent made in the setup wizard starts in Learn, whoever makes it. In Learn it reads, keeps notes and proposes facts. It doesn't draft replies, ask anyone, file anything or change anything in Jira or Confluence. New agents also come with working hours on, invited work only, a second check on customer replies and a daily token cap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F4391b5270adb01d889a17fb5f419c7ca6756537f-2080x780.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F4391b5270adb01d889a17fb5f419c7ca6756537f-2080x780.png" title="The Agents tab says the rule in its own words: an agent starts in Learn and changes nothing until you promote it. This one is a demo persona in Shadow with 3 drafts staged. CogniRunner 6.0.0 production install on our demo site, captured 2 October 2026." alt="CogniRunner Agents tab on the demo site: the tab text says a virtual administrator starts in Learn, where it reads and takes notes and changes nothing until you promote it, and a list with one agent, Ines Calder, paused, phase Shadow, 3 staged drafts" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only a CogniRunner admin can move it up. If someone who isn't an admin tries to choose a phase, they get Learn anyway. An admin can also schedule the move for after a set number of runs (the "Set an automatic move" link in the next screenshot), but the agent never picks its own phase.&lt;/p&gt;

&lt;p&gt;Shadow adds drafts. The agent writes the replies and records the changes it would make, and a person approves or rejects each one. Ask first lets it ask people questions, file change proposals and run a request someone approved. Live lets it post answers and make changes within its powers, its working hours and its caps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F1124ac1e53e7abdad16e46d4ec19702280a61f18-2080x1110.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F1124ac1e53e7abdad16e46d4ec19702280a61f18-2080x1110.png" title="Look at two things: the 3 drafts waiting under Needs you, and the Brakes panel on the right with the 250,000 tokens a day cap. CogniRunner 6.0.0 on our demo site, 2 October 2026." alt="A CogniRunner agent's home page: the phase strip Learn, Shadow, Ask first and Live with Shadow selected, the line It stays in Shadow until a person moves it, a Set an automatic move to Ask first link, a Needs you card with 3 drafts on 2 issues waiting for approval, and a Brakes panel reading 6 messages an hour, 30 a day, 200 changes a run, 250,000 AI tokens a day with 173,321 used today" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those brakes are worth a second look. The demo agent may send 6 messages an hour and 30 a day, and make at most 200 changes in one run. It also only changes the issue it's working on. If it decides another issue needs a change, that's recorded for a person to make. And a reply that fails its second check three times stops and waits for a person.&lt;/p&gt;

&lt;p&gt;There's a check on every run too. The agent acts for a named person, and before each run CogniRunner asks Jira whether that person's account is still active and can still browse every project it reads and writes. If it's allowed Atlassian operations (more on those below), that person also needs Jira's Administer permission. If any answer is no, or Jira can't answer, the agent runs in Learn. A lasting no stays until an admin reverses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delete, configuration and identity calls wait for a Jira administrator
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want to know about if I were deciding whether to let an agent near my site. So let's be precise.&lt;/p&gt;

&lt;p&gt;Even in Live, a CogniRunner agent never deletes anything, changes configuration or touches users and groups straight from a model turn. In Ask first and Live those calls wait under "Waiting for your approval", showing exactly what would be sent. In Learn and Shadow they don't run at all.&lt;/p&gt;

&lt;p&gt;A few details make that approval mean something:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The approver has to hold Jira's Administer permission, checked live at the moment they click. If Jira can't answer, the approval is refused.&lt;/li&gt;
&lt;li&gt;The approval is tied to the exact request. If what's waiting isn't what you approved, nothing runs, and it only runs while the thing it changes still looks the way it did when the request was filed.&lt;/li&gt;
&lt;li&gt;A request expires after 24 hours. One agent can have at most 20 waiting.&lt;/li&gt;
&lt;li&gt;A token can only approve as the Jira administrator who created it, and a token with no person behind it can't approve at all. A model can never approve its own request.&lt;/li&gt;
&lt;li&gt;No agent can change the org-admins, site-admins or cognirunner-guardians groups, even with approval.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F5c73d38152ad5a4f1f5a581937ef44078b041e48-1460x1045.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F5c73d38152ad5a4f1f5a581937ef44078b041e48-1460x1045.png" title="The approval queue and the allowlist, both empty on purpose: this demo agent has no Atlassian operations allowed, so nothing can wait. Note the 0 of 40 limit. CogniRunner 6.0.0 on our demo site, 2 October 2026." alt="CogniRunner Atlassian calls tab for one agent: Waiting for your approval says nothing is waiting and that a request to delete something or change configuration, users or groups would wait here instead of being sent; Try a request says there is nothing to try yet; Operations it may call shows 0 of 40, and says a call that deletes something or changes configuration, users or groups always waits for a Jira administrator's approval" width="800" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To be clear about what you're looking at, that queue is empty. Our demo agent isn't allowed any operations, so I haven't got a real waiting request to show you here.&lt;/p&gt;

&lt;p&gt;The middle card, Try a request, is the one I'd use first. It shows what a call would pass without sending it. On this agent it has nothing to try, because nothing's allowed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI agent guardrails: allowed operations, a guard dog and a token cap
&lt;/h2&gt;

&lt;p&gt;Atlassian's guide to &lt;a href="https://www.atlassian.com/software/jira/guides/agentic-engineering/guardrails-and-safety" rel="noopener noreferrer"&gt;AI agent guardrails and safety in Jira&lt;/a&gt; has a line I really like: "A guardrail only works if the system enforces it, not the agent." CogniRunner has two that work that way, the allowlist and the token cap, plus a guard dog that watches for what gets past them.&lt;/p&gt;

&lt;p&gt;The allowlist comes first. Beyond its ordinary actions, an agent can call only the Atlassian REST operations on its own list, up to 40, picked from Jira, Jira Service Management, Assets and Confluence. Only a Jira administrator can add one. The catalogue is built from Atlassian's published API specs, and the model never names a host, a path or a method itself.&lt;/p&gt;

&lt;p&gt;The token cap is about money. Atlassian's guardrails guide lists "Runaway cost" as a risk and says spending "needs limits like any other resource". It's not abstract on the Rovo side either: Atlassian's Jira docs say Rovo credit allowances apply from 3 December 2026. New CogniRunner agents get a cap of 250,000 AI tokens a day. When it's spent, the work waits for the next day (UTC) and the agent's history says why. One turn that's already running can go a little over, and the next one is refused. The budget is also checked inside each turn, and a Learn turn uses at most three rounds.&lt;/p&gt;

&lt;p&gt;Then there's the guard dog. It watches issue deletes on the whole site, by anyone, people included. It also notices when automation answers someone's bulk change. When one account deletes 10 or more issues in 10 minutes, or 3 in a project you've marked protected, it notices. Both numbers are adjustable on its card, which only organisation admins see. Once you've set an inbox project, it files an incident issue there and notifies the organisation admins and the leads of the affected projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up the controls on your own site
&lt;/h2&gt;

&lt;p&gt;If you're going to try either, here's the order I'd do it in.&lt;/p&gt;

&lt;p&gt;For Rovo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Decide who can create agents in Studio settings: all users, up to 10 groups, or nobody but org admins.&lt;/li&gt;
&lt;li&gt;Restrict any sensitive agent to the named people who need it. Remember it's one user at a time.&lt;/li&gt;
&lt;li&gt;Give agents that run in automations an Agent's account, so their changes show under the agent's name.&lt;/li&gt;
&lt;li&gt;In each automation's Use agent step, choose read actions only wherever the agent just needs to read.&lt;/li&gt;
&lt;li&gt;Add a workflow approval step on the transitions where the impact lands, if you're on Premium or Enterprise.&lt;/li&gt;
&lt;li&gt;If you have Guard Premium, check the "Invoked Rovo agent" events after the first week.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For CogniRunner:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the agent with "New virtual administrator" on the Agents tab. It lands in Learn.&lt;/li&gt;
&lt;li&gt;Leave it in Learn for a while and read what it writes down before you trust it with anything.&lt;/li&gt;
&lt;li&gt;Move it to Shadow and approve or reject its drafts. You'll learn more from the ones you reject.&lt;/li&gt;
&lt;li&gt;Only then have a Jira administrator allow Atlassian operations, the fewest it needs, and run each one through Try a request.&lt;/li&gt;
&lt;li&gt;Ask an organisation admin to set the guard dog's inbox project and mark your protected projects.&lt;/li&gt;
&lt;li&gt;Check the token cap under Brakes, and move to Ask first or Live only when the drafts stop surprising you.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What CogniRunner doesn't do
&lt;/h2&gt;

&lt;p&gt;A few honest limits, because they change the decision.&lt;/p&gt;

&lt;p&gt;On Marketplace, a CogniRunner agent doesn't get its own Jira account. It writes as the app, so Jira shows the app's name on its changes, and only within what the person it acts for may do. That's the opposite of Rovo's Agent's account, which really is a separate identity.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F0cc284f3cdd535ee8a32685bf0a2520bc55584ec-2080x935.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F0cc284f3cdd535ee8a32685bf0a2520bc55584ec-2080x935.png" title="On a Marketplace install the agent writes as the app, and the guard dog can raise the alarm about a bulk change but can't switch the automation rule off. All three credentials read not set. CogniRunner 6.0.0 on our demo site, 2 October 2026." alt="CogniRunner Connections and keys card: this build stores no Atlassian credentials, a Marketplace rule, so bots work as the app; the guard dog's automation credential is not set, and without it the guard dog only tells the organisation admins; the agent's service account is not set, so it writes as the app and only within what Mihai Perdum, who it acts for, may do" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The guard dog detects and tells people. That's all. It never restores anything and never blocks a delete, and on Marketplace it can't switch off the automation rule behind a bulk change either. It also only watches Jira issues, not Confluence: issue deletes, and bulk changes that automation answers.&lt;/p&gt;

&lt;p&gt;The admin approval covers deletes, configuration and identity calls made through the allowed operations. It isn't a gate on every write. In Live, ordinary changes run within the agent's powers and caps, and replies go through the staged drafts you approve in Shadow and Ask first.&lt;/p&gt;

&lt;p&gt;And the AI. On Standard, the agents run on your own AI provider, any CogniRunner supports except goose, which can't make the tool calls an agent needs (there's a tutorial on &lt;a href="https://leanzero.net/tutorials/cognirunner-jira-openai-key-workflow-validator?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;connecting an OpenAI key&lt;/a&gt;). On Atlassian's zero-key Forge LLM, they need the Coder edition and one of its frontier models, the same edition behind the &lt;a href="https://leanzero.net/tutorials/cognirunner-coder-jira-coding-agent-github?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;CogniRunner Coder&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rovo or CogniRunner: pick by who should say yes
&lt;/h2&gt;

&lt;p&gt;They're not really competitors, and you can run both.&lt;/p&gt;

&lt;p&gt;In chat, a Rovo agent acts as the person using it, and they confirm consequential actions. On a work item someone assigned it, it acts as them too, and Atlassian's own guide says the approval checkpoint is one you add. You control who can use it, one user at a time for now.&lt;/p&gt;

&lt;p&gt;If the agent works a queue on its own, Rovo can do that too, in an automation and ideally on an Agent's account, with a workflow approval step on the transition. CogniRunner's difference is that a Jira administrator signs off on each destructive call before it's sent, and the agent climbs phases only when an admin decides. The &lt;a href="https://leanzero.net/portfolio/cognirunner?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;CogniRunner page&lt;/a&gt; has two short videos of the setup and the phases if you'd like to see it move.&lt;/p&gt;

&lt;p&gt;Either way, the workflow approval step Atlassian recommends is still worth adding on the transitions where the impact lands. A second gate that doesn't depend on the agent is the whole point of a guardrail :)&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/blog/rovo-agent-permissions-cognirunner-approvals?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cognirunner</category>
      <category>jira</category>
    </item>
    <item>
      <title>How to Lock Attachments in Confluence Cloud</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Sun, 04 Oct 2026 05:02:33 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/how-to-lock-attachments-in-confluence-cloud-50jj</link>
      <guid>https://dev.to/mihai_leanzero/how-to-lock-attachments-in-confluence-cloud-50jj</guid>
      <description>&lt;p&gt;Confluence Cloud has no built-in way to lock attachments. The request for one, &lt;a href="https://jira.atlassian.com/browse/CONFCLOUD-2841" rel="noopener noreferrer"&gt;CONFCLOUD-2841 "Provide locking for attachments"&lt;/a&gt;, has been open since 2005 and carries 163 votes. Sentinel Vault, the Confluence app we make, gets you the closest working thing: you seal the file, and when somebody uploads over it, the sealed version is back on top a few seconds later. On our production install that took between 2.6 and 4.6 seconds, over three runs on 2 October.&lt;/p&gt;

&lt;p&gt;It isn't a lock in the check-out sense. I'd rather you know that before Step 1. A Forge app can't stop Confluence from saving an upload. So Sentinel Vault lets the save happen, downloads the sealed version and uploads it again as a new version. The rejected upload stays in the version history. That's useful when someone's change was right after all. We wrote up why it has to work this way in &lt;a href="https://leanzero.net/blog/sentinel-vault-detect-and-restore-confluence?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;you can't block a Confluence save&lt;/a&gt;, so I won't repeat it here.&lt;/p&gt;

&lt;p&gt;By the end you'll have one file sealed on a real page, a second account's overwrite reverted, both versions visible in the attachment history and over the REST API, and the seal released again. One more thing up front, because it changes how you use it. Running this, we hit a bug in our own app: if you sealed the file and then upload a new version yourself, the seal keeps pointing at the old one. The workaround is in the "If it didn't work" section below.&lt;/p&gt;

&lt;p&gt;We ran the walkthrough below on 2 October 2026 on our demo site, which runs the production build, 6.6.0, signed in as me with Gabriela's account as the second user. The two checks that ran on our test site instead say so where they appear. Afterwards the page was deleted and purged.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
&lt;strong&gt;Prerequisites&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Confluence Cloud site and a site admin who can install apps. Sentinel Vault is on the &lt;a href="https://marketplace.atlassian.com/apps/1034857304" rel="noopener noreferrer"&gt;Atlassian Marketplace&lt;/a&gt;. Atlassian bills it, and on 2 October the listing's pricing data priced the 10-user tier at 0. This tutorial was written against 6.6.0, released 2 October.&lt;/li&gt;
&lt;li&gt;Edit permission on the page that holds the file. That's all sealing needs.&lt;/li&gt;
&lt;li&gt;A second account that can also edit the page, to play the person who overwrites the file.&lt;/li&gt;
&lt;li&gt;For Steps 3 and 5 on the command line: an &lt;a href="https://id.atlassian.com/manage-profile/security/api-tokens" rel="noopener noreferrer"&gt;Atlassian API token&lt;/a&gt; for each account, plus curl, jq and shasum. We ran them with curl 8.7.1 and jq 1.7.1 on macOS.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;[[steps]]&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install Sentinel Vault&lt;/strong&gt; — a site admin, once, from the Marketplace listing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seal the attachment&lt;/strong&gt; — page ⋯, Apps, Seal attachments…, tick the file, Seal 1 attachment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overwrite it as another user&lt;/strong&gt; — same file name, same page, second account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the attachment version history&lt;/strong&gt; — the rejected upload and the app's revert, side by side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List the versions over the REST API&lt;/strong&gt; — and hash them to prove the sealed bytes came back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release the seal&lt;/strong&gt; — Release on the file's row, and uploads stand again.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A seal remembers one version and puts it back
&lt;/h2&gt;

&lt;p&gt;One picture before the clicks. It's short. When you seal a file, Sentinel Vault records which version it is (v1 here). From then on, every new upload to that file triggers a check, and who uploaded it decides what happens.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Who uploads&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anyone without access&lt;/td&gt;
&lt;td&gt;Reverted: the sealed version comes back as a new version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Someone you gave edit access&lt;/td&gt;
&lt;td&gt;Kept, and it becomes the new sealed version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You, the person who sealed it&lt;/td&gt;
&lt;td&gt;Kept, but the seal still points at the old version (the bug)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anyone, after the seal expires&lt;/td&gt;
&lt;td&gt;Kept. An expired seal stops enforcing straight away&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One exception: on a page that is Approved in a Sentinel Vault workflow, only its approvers and space admins can seal or change a file, so even you and your grantees get reverted there.&lt;/p&gt;

&lt;p&gt;The seal follows the attachment's id, not its name. Remember that. It matters later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Install Sentinel Vault from the Atlassian Marketplace
&lt;/h2&gt;

&lt;p&gt;A site admin installs it once, from the &lt;a href="https://marketplace.atlassian.com/apps/1034857304" rel="noopener noreferrer"&gt;listing&lt;/a&gt;, and accepts the permissions it asks for.&lt;/p&gt;

&lt;p&gt;There's no switch to flip after that. I like it that way. Sealing isn't behind a site setting, and anyone who can edit a page can seal its files (Approved pages are the exception, see above). The defaults that matter here: a seal lasts 48 hours unless a space sets its own default, and the app tells an editor when their change is undone. Signed seal actions (a 6-digit authenticator code) are off by default. Leave them off for this test. I'd turn them on later, once people know what a seal is.&lt;/p&gt;

&lt;p&gt;To check it worked, open any page. Under the title, next to the author, you'll see a small &lt;strong&gt;Sentinel Vault&lt;/strong&gt; item in the byline. That's the app's door on every page. No item, no install.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Seal (lock) an attachment on a Confluence Cloud page
&lt;/h2&gt;

&lt;p&gt;Attach the file you want to protect to a page. Our test used a 93-byte &lt;code&gt;price-list.csv&lt;/code&gt; on a page called "Supplier price list Q4 (sealed file demo)".&lt;/p&gt;

&lt;p&gt;Open the page's &lt;strong&gt;⋯&lt;/strong&gt; menu at the top right, choose &lt;strong&gt;Apps&lt;/strong&gt;, then &lt;strong&gt;Seal attachments…&lt;/strong&gt;. Our own README says "choose Seal attachments…" and skips the &lt;strong&gt;Apps&lt;/strong&gt; step. On current Confluence Cloud it sits one level down, under Apps. You'll need it. (Yes, our README is wrong there. It's on the list.)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F298706b24b00de5c689335e674a70087646246b7-1920x1264.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F298706b24b00de5c689335e674a70087646246b7-1920x1264.png" title="The door is one level down: ⋯, Apps, Seal attachments…. Production install (6.6.0) on our demo site, 2 October 2026." alt="Confluence page More actions menu open with the Apps item highlighted and a submenu showing Seal attachments…" width="800" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The dialog lists every file on the page with a checkbox. Tick the one you want. Then set two fields:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Seal holds for.&lt;/strong&gt; The first option is the space default (it read "Space default (1 day)" on our demo space). The rest of the list runs from 1 day to 1 year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note (optional).&lt;/strong&gt; Why it's sealed, up to 300 characters. People see it with the seal.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'd pick the shortest duration that covers the job. A seal that outlives its reason just trains people to ask for edit access.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Seal 1 attachment&lt;/strong&gt;. If your site has signed seal actions switched on, you'll get a &lt;strong&gt;Sign this action&lt;/strong&gt; box first. Type the current code from your authenticator and click &lt;strong&gt;Sign and continue&lt;/strong&gt;. If you've never set one up, the app sends you to its My work page to scan a QR code. That takes a minute. On a default site you won't see any of it.&lt;/p&gt;

&lt;p&gt;You should see a green &lt;strong&gt;1 attachment sealed.&lt;/strong&gt; bar. The file's row now carries a &lt;strong&gt;SEALED · YOURS&lt;/strong&gt; label and the expiry.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F5c9ae8c2bb34e89b70b0a805f7a062de600d44d1-1600x880.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F5c9ae8c2bb34e89b70b0a805f7a062de600d44d1-1600x880.png" title="After sealing: the row names the sealed version (v1), the expiry, and offers Release. The fields you just used sit underneath." alt="Seal attachments dialog after sealing: price-list.csv shows a SEALED · YOURS label, v1 until Sun 00:04, a Release button, and a green bar saying 1 attachment sealed." width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The byline item under the title changes to &lt;strong&gt;Sealed (1)&lt;/strong&gt;. Good. Now go and break it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Overwrite the sealed file as another user
&lt;/h2&gt;

&lt;p&gt;Sign in as the second account and upload a file with the same name to the same page. In the browser, that's dragging the file onto the page or uploading it from the attachments list. Confluence treats a matching name as a new version of the same attachment.&lt;/p&gt;

&lt;p&gt;We did it from the command line, so the timestamps would be exact. A &lt;code&gt;PUT&lt;/code&gt; on the page's attachments creates or updates by filename:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SITE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://your-site.atlassian.net
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PAGE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;31129606          &lt;span class="c"&gt;# the page id from the page's URL&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SECOND_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...          &lt;span class="c"&gt;# the second account's API token&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"second.user@example.com:&lt;/span&gt;&lt;span class="nv"&gt;$SECOND_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wiki/rest/api/content/&lt;/span&gt;&lt;span class="nv"&gt;$PAGE_ID&lt;/span&gt;&lt;span class="s2"&gt;/child/attachment"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-Atlassian-Token: no-check'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'file=@price-list.csv'&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'comment=Updated A-100 price'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.results[0] | "\(.id) \(.title) v\(.version.number) \(.version.by.displayName) \(.version.when) \(.version.message)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;att31490051 price-list.csv v2 Gabriela Perdum 2026-10-02T21:05:20.666Z Updated A-100 price
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confluence accepted it as v2. That part is meant to happen. Don't panic. Wait five seconds, then reload the page as yourself.&lt;/p&gt;

&lt;p&gt;As the owner, you should see a banner at the top: "A change to your sealed attachment price-list.csv was reverted automatically."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F665f900f5a407aa23a19c21620761fe010f5b674-2240x645.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F665f900f5a407aa23a19c21620761fe010f5b674-2240x645.png" title="What the owner sees on the next visit: an Undone banner naming the file, and Sealed (1) in the byline." alt="Sentinel Vault banner across the top of the page reading Undone, A change to your sealed attachment price-list.csv was reverted automatically, with an Open button, and the page byline showing Sealed (1)" width="799" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The person who uploaded gets told too. On a default site they get a page comment headed "Your change was undone", which says the file is sealed and how to ask for edit access. We didn't see that exact comment, because our demo site has the app's comment switches on. There, a fuller "Seal Violation" comment went out, naming both of us. Either way, each reverted upload gets its own comment. Three stubborn uploads, three comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Read the attachment version history in Confluence
&lt;/h2&gt;

&lt;p&gt;Confluence's attachment version control is where you can see what happened. Open the page's attachments list. The quickest way is this URL, with your page id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-site.atlassian.net/wiki/pages/viewpageattachments.action?pageId=31129606
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expand the file. Each overwrite leaves two rows: the upload that was rejected, and above it a version by Sentinel Vault with the comment "(Sentinel Vault automatically reversed modifications)".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F40c1888c1d7d0a42b5dc6a6ebcc1729d806b3502-2110x940.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3oa2omis%2Fproduction%2F40c1888c1d7d0a42b5dc6a6ebcc1729d806b3502-2110x940.png" title="Two reverts in one history. Versions 3 and 6 are the app. Version 6 put back version 1's bytes, not my own version 4. That's the bug explained under If it didn't work, below. Times shown in UTC+3." alt="Confluence Attachments page for price-list.csv with versions 1 to 6: versions 2 and 5 by Gabriela Perdum, versions 3 and 6 by Sentinel Vault with the comment Sentinel Vault automatically reversed modifications, version 4 by Mihai Perdum Owner correction A-200" width="799" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ours has six versions because we kept going after the first revert, and that's how the bug showed up. You should see three: your original, the other account's upload, and the app's revert. The rejected upload stays there. If it turns out to be the right file, nobody lost it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: List attachment versions with the Confluence REST API
&lt;/h2&gt;

&lt;p&gt;The history page is fine for one file. It doesn't scale. For an audit, or to prove the revert really restored the same bytes, use the REST API. First, the attachment id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...                 &lt;span class="c"&gt;# your own API token&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"you@example.com:&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wiki/api/v2/pages/&lt;/span&gt;&lt;span class="nv"&gt;$PAGE_ID&lt;/span&gt;&lt;span class="s2"&gt;/attachments"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.results[] | "\(.id) \(.title) v\(.version.number)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should print one line per file: id, name and current version. This one is from our test site, where the file was still at v2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;att380698656 price-list.csv v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you used the &lt;code&gt;PUT&lt;/code&gt; in Step 3, the id is already the first field it printed. Then list the versions. The call should print three lines, newest first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ATT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;att31490051

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"you@example.com:&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wiki/api/v2/attachments/&lt;/span&gt;&lt;span class="nv"&gt;$ATT_ID&lt;/span&gt;&lt;span class="s2"&gt;/versions"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.results[] | "\(.number)  \(.createdAt)  \(.message)  minorEdit=\(.minorEdit)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3  2026-10-02T21:05:25.239Z  (Sentinel Vault automatically reversed modifications)  minorEdit=true
2  2026-10-02T21:05:20.666Z  Updated A-100 price  minorEdit=false
1  2026-10-02T20:56:13.026Z  Signed Q4 list  minorEdit=false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's your revert time. Read it off the timestamps. Version 2 landed at 21:05:20.666 and version 3 at 21:05:25.239, so 4.6 seconds. The other two production runs came in at 2.6 and 2.8. The app marks its own upload as a minor edit.&lt;/p&gt;

&lt;p&gt;To prove v3 is really v1, hash each version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;v &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"you@example.com:&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wiki/rest/api/content/&lt;/span&gt;&lt;span class="nv"&gt;$PAGE_ID&lt;/span&gt;&lt;span class="s2"&gt;/child/attachment/&lt;/span&gt;&lt;span class="nv"&gt;$ATT_ID&lt;/span&gt;&lt;span class="s2"&gt;/download?version=&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | shasum &lt;span class="nt"&gt;-a&lt;/span&gt; 256
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;9ae3291c…bdd9  -
103987535b…055e  -
9ae3291c…bdd9  -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Hashes shortened.) Versions 1 and 3 match. Version 2 is the one that got rejected. Same bytes, proven. Keep the &lt;code&gt;-L&lt;/code&gt;. Without it, Confluence answers &lt;code&gt;302&lt;/code&gt; (we checked) and you hash a redirect. Jira does the same with a 303, written up in &lt;a href="https://leanzero.net/blog/atlassian-attachment-download-redirect?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Jira attachment download: 303, not your file&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Release the seal to unlock the attachment
&lt;/h2&gt;

&lt;p&gt;When the file is allowed to change again, release it. Don't leave seals lying around. Click &lt;strong&gt;Sealed (1)&lt;/strong&gt; in the byline (or open &lt;strong&gt;⋯&lt;/strong&gt;, &lt;strong&gt;Apps&lt;/strong&gt;, &lt;strong&gt;Seal attachments…&lt;/strong&gt; again) and press &lt;strong&gt;Release&lt;/strong&gt; on the file's row. On a site with signed actions, you'll type a code here too.&lt;/p&gt;

&lt;p&gt;The Overview tab then reads "SEALS ON THIS PAGE · 0", and the activity list says you unsealed the file.&lt;/p&gt;

&lt;p&gt;We uploaded once more after releasing and waited 30 seconds. The upload stayed. Unlocked. If you'd rather not release by hand, don't: a seal stops enforcing the moment its time runs out. After that the owner sees an overdue notice on the page once a day, and after the third one the app releases the seal (those are the defaults; reminder comments need the app's comment switch on).&lt;/p&gt;

&lt;h2&gt;
  
  
  Give one person edit access instead of unlocking (optional)
&lt;/h2&gt;

&lt;p&gt;Sometimes one colleague needs to change the file and nobody else should. Releasing is the wrong tool for that. Don't. On the Overview tab, open the file row's &lt;strong&gt;⋯&lt;/strong&gt; and choose &lt;strong&gt;Give edit access…&lt;/strong&gt;, then pick the person.&lt;/p&gt;

&lt;p&gt;What we measured: Gabriela's upload after the grant stayed, and it became the new sealed version. When her access was revoked (under "Editors with access" in the full attachments view), her next upload was reverted to her granted version, not to my original. A grant moves the seal forward. That's the behaviour I want. Someone without edit access can press &lt;strong&gt;Request edit&lt;/strong&gt; on the file instead. That one we only read in the code, not tested from the requester's seat.&lt;/p&gt;

&lt;h2&gt;
  
  
  If it didn't work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You sealed it, uploaded a fix yourself, and the next overwrite brought back the old file.&lt;/strong&gt; That's my bug, in 6.6.0. Your own upload is allowed, but the seal doesn't adopt it as the new sealed version. In our run my account uploaded v4 with a correction. Gabriela's account then uploaded v5, and the app restored v1's bytes as v6. My correction was still in the history, but it wasn't current anymore. That one's on me. My app, my bug. The workaround: after uploading your own new version, press &lt;strong&gt;Release&lt;/strong&gt; and seal the file again. The new seal adopts your version. Releasing drops any edit access you gave, and the new seal starts a fresh duration, so give access again afterwards. It's logged for a fix, and until one ships, do it that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Attachment is already sealed by another user".&lt;/strong&gt; Somebody got there first. Ask them for edit access, or wait for the seal to run out. An expired seal from someone else is cleared when you seal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"You do not have permission to seal this file".&lt;/strong&gt; You can view the page but not edit it. Sealing follows Confluence's own edit permission: the dialog still opens, but the server refuses anyone who can't edit the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"This page is Approved — only its approvers or a space admin can seal on it."&lt;/strong&gt; The page is Approved in a Sentinel Vault workflow. Ask an approver, or seal before approval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Set up your signature first".&lt;/strong&gt; Your site signs seal actions. Use &lt;strong&gt;Set up on My work&lt;/strong&gt;, scan the QR code with an authenticator app, type the first code and come back. Five wrong codes lock signing for 15 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Someone renamed the file and nothing was reverted.&lt;/strong&gt; Correct, renames aren't reverted. We tested it on our test site's development build: the rename stuck. But the next overwrite of the renamed file was reverted to the sealed bytes. The seal follows the attachment id, so the content is still protected under its new name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Someone moved the file to the trash.&lt;/strong&gt; That one comes back. Fast. Gabriela's account deleted the sealed file over the API, and it was current again within two seconds, with no new version. If the person who sealed it trashes it, though, the seal is released along with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The upload was never reverted.&lt;/strong&gt; Check the seal hasn't expired: an expired seal stops enforcing at once, even though it stays listed until the app releases it about three days later. If the revert itself fails (the app gives up after three attempts), the owner gets a notice saying so. There's no later sweep that catches a missed upload. Read that notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a seal does not do
&lt;/h2&gt;

&lt;p&gt;A seal protects a file's content, not who can read it. Everyone who can see the page can still view and download the sealed file. If you need that restricted, it's a Confluence page restriction, not a seal.&lt;/p&gt;

&lt;p&gt;It doesn't stop the save. For a few seconds the rejected file is the current version, and anyone who downloads it in that window gets the rejected file.&lt;/p&gt;

&lt;p&gt;Seals made through Sentinel Vault's own REST API skip the authenticator check, even on a site that signs everything else. So does a sealed section created by inserting the macro in the editor. If your auditors care about signatures, keep sealing in the UI. I would.&lt;/p&gt;

&lt;p&gt;And it isn't a check-out tool. Tools like Lockpoint are built around checking a file out before you edit it, and Sentinel Vault has no check-out at all. It lets the upload in and puts the sealed file back. For a signed price list or a contract, putting it back is exactly what you want. For a team that edits the same Word file all day, it's the wrong tool. I'd tell you that before you buy it.&lt;/p&gt;

&lt;p&gt;There's more in the app, like section seals, approvals and the AI review, and it's all on the &lt;a href="https://leanzero.net/portfolio/sentinel-vault?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Sentinel Vault page&lt;/a&gt;. For locking attachments, the six steps above are the whole job. That's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you have now
&lt;/h2&gt;

&lt;p&gt;[[takeaways]]&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have a file sealed on a Confluence Cloud page, an overwrite by a second account reverted in seconds, both versions in the attachment history and in the REST API, and the seal released.&lt;/li&gt;
&lt;li&gt;The path is ⋯, Apps, Seal attachments…. Anyone who can edit the page can seal, unless the page is Approved in a workflow.&lt;/li&gt;
&lt;li&gt;A seal reverts, it doesn't block. The rejected upload stays in the history, and the app's version carries "(Sentinel Vault automatically reversed modifications)".&lt;/li&gt;
&lt;li&gt;Prove a revert with &lt;code&gt;GET /wiki/api/v2/attachments/{id}/versions&lt;/code&gt; and a sha256 of each version. Keep &lt;code&gt;-L&lt;/code&gt; on the download.&lt;/li&gt;
&lt;li&gt;Give edit access when one person should change the file. Their upload becomes the new sealed version.&lt;/li&gt;
&lt;li&gt;In 6.6.0, after uploading your own new version of a file you sealed, release and seal again.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/tutorials/lock-attachments-confluence-cloud-sentinel-vault?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>confluence</category>
      <category>atlassian</category>
      <category>forge</category>
      <category>sentinelvault</category>
    </item>
    <item>
      <title>Suppressing the email when Jira Automation adds a comment (and why sendNotifications does nothing)</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Sat, 03 Oct 2026 23:50:35 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/suppressing-the-email-when-jira-automation-adds-a-comment-and-why-sendnotifications-does-nothing-1pc5</link>
      <guid>https://dev.to/mihai_leanzero/suppressing-the-email-when-jira-automation-adds-a-comment-and-why-sendnotifications-does-nothing-1pc5</guid>
      <description>&lt;h1&gt;
  
  
  Suppressing the email when Jira Automation adds a comment (and why sendNotifications does nothing)
&lt;/h1&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;sendNotifications is not a parameter of the automation Comment action, so nothing reads it. Your export proves the key is stored; your inbox proves it is not used.&lt;/li&gt;
&lt;li&gt;The Jira comment REST endpoint ignores unknown keys silently. I posted sendNotifications, notifyUsers and totallyMadeUpKey12345 to it and all three returned 201.&lt;/li&gt;
&lt;li&gt;notifyUsers is real — on Edit issue, on Update comment and on the worklog endpoints. The one place it does not exist is ADD comment, which is exactly the operation automation performs.&lt;/li&gt;
&lt;li&gt;Sending a typed parameter a bad value tells you whether it is declared on that endpoint. It does NOT tell you whether your account may use it — that is a separate permission gate, and a missing permission is ignored silently.&lt;/li&gt;
&lt;li&gt;I verified the REST mechanics on a live Jira Cloud site. I did not verify that the email itself stopped, because no API reports it — treat suppression as documented, not measured.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Somebody on the Atlassian Community had done everything right and it still did not work. They exported a Jira Automation rule, hand-added &lt;code&gt;"sendNotifications": false&lt;/code&gt; to the &lt;code&gt;jira.issue.comment&lt;/code&gt; action, re-imported it without an error, exported it again to check — the key was still there — and then watched the rule send "Issue Commented" email to every watcher anyway. They were staring down a backfill of about 85,000 work items.&lt;/p&gt;

&lt;p&gt;That is a good bug report, and the answer is not the one people usually give.&lt;/p&gt;

&lt;p&gt;The flag does nothing because it is not a parameter of that action. Nothing in the automation engine looks at it. Their export proved the key was &lt;strong&gt;stored&lt;/strong&gt;; their inbox proved it was &lt;strong&gt;not read&lt;/strong&gt;. Those are two different things, and Jira's APIs are unusually willing to let you confuse them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Everything below was checked on 2 August 2026 against a live Jira Cloud site. Ticket statuses and vote counts move; I have dated each one so you can tell when this page has gone stale.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Check the rule builder before you touch any of this
&lt;/h2&gt;

&lt;p&gt;Start here, because if it applies to you the rest of this article is unnecessary.&lt;/p&gt;

&lt;p&gt;AUTO-602's own description opens: &lt;em&gt;"Currently, we can only suppress Email notifications for the *&lt;/em&gt;&lt;strong&gt;Edit Issue&lt;/strong&gt;** action performed by automation or Jira."* That sentence implies the automation &lt;strong&gt;Edit work item&lt;/strong&gt; action has an email-suppression control. Historically it did — a "send notifications" checkbox.&lt;/p&gt;

&lt;p&gt;I could not confirm its current state. Atlassian's automation actions documentation does not list any notification option for either the Edit or the Comment action, and community reports disagree about whether the checkbox still exists in the current rule builder. So: open your own rule builder, open an Edit work item action, and look. If the control is there, and you can restructure your rule to set a field rather than post a comment, that is the cleanest answer available and it is entirely in-product.&lt;/p&gt;

&lt;p&gt;If it is not there, or you genuinely need a comment, carry on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the flag is ignored
&lt;/h2&gt;

&lt;p&gt;The automation &lt;strong&gt;Comment on work item&lt;/strong&gt; action exposes the comment text, smart values inside it, and the comment's visibility. Atlassian describes it as "Adds a comment to a work item. You can use smart values to reference work item fields to personalize the comment. You can also set the comment visibility." There is no notification control documented, and no hidden one that anybody has demonstrated.&lt;/p&gt;

&lt;p&gt;The round-trip fooled everybody because the exported JSON survives re-import untouched. The most likely explanation is that the importer keeps keys it does not recognise and hands the action only the fields the action knows about — I did not reproduce that inside the automation engine, so treat the mechanism as inference. What is not inference is the behaviour of the platform underneath it, which I did test, and which does exactly the same thing.&lt;/p&gt;

&lt;p&gt;Do not conflate this with the comment &lt;strong&gt;visibility&lt;/strong&gt; setting, which is real and documented on the action. Visibility controls who can see the comment — in Jira Service Management, whether it is internal or customer-facing. It is a different axis and it does nothing to Jira notification-scheme email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where notifyUsers actually exists
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;notifyUsers=false&lt;/code&gt; is a genuine Jira Cloud parameter. It appears on &lt;strong&gt;Edit issue&lt;/strong&gt;, on &lt;strong&gt;Update comment&lt;/strong&gt;, and on the three worklog endpoints. The one operation that does not have it is &lt;strong&gt;add&lt;/strong&gt; comment — which is precisely the operation an automation rule performs when it comments.&lt;/p&gt;

&lt;p&gt;That gap is what &lt;strong&gt;JRACLOUD-97682&lt;/strong&gt; ("Add notifyUsers=false option to comments API") exists to close. Read 2 August 2026: Gathering Interest, Unresolved, 1 vote, 2 watchers, created 4 March 2026. Its Workaround section gives the route round the gap — add the comment as part of an issue &lt;em&gt;edit&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://your-site.atlassian.net/rest/api/3/issue/ABC-1?notifyUsers=false'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="s1"&gt;'you@example.com:YOUR_API_TOKEN'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "update": {
      "comment": [
        { "add": { "body": {
            "type": "doc", "version": 1,
            "content": [ { "type": "paragraph", "content": [
              { "type": "text", "text": "Backfilled by the migration script." } ] } ]
        } } }
      ]
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "just call the API instead of using automation" is only a fix if you call &lt;em&gt;that&lt;/em&gt; endpoint. Posting to &lt;code&gt;/issue/{key}/comment&lt;/code&gt; gets you nowhere no matter what you attach to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AUTO-602&lt;/strong&gt; is the ticket to cite for the asymmetry itself. Read 2 August 2026: Gathering Interest, Unresolved, 1,068 votes, 513 watchers, created 29 April 2020. Six years of votes, still a suggestion.&lt;/p&gt;

&lt;p&gt;Quote it carefully. Its &lt;em&gt;title&lt;/em&gt; is scoped to in-product and in-app notifications, but its description asks for "email notifications as well as the Notifications on the instance". People cite it for one and get corrected on the other; the accurate framing is that the title and the description cover different scopes, and the opening sentence is what settles the Edit-versus-everything-else question.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not reach for &lt;strong&gt;JRACLOUD-78140&lt;/strong&gt; ("Implement notifyUsers parameter to a number of API calls"). Checked 2 August 2026: &lt;strong&gt;Closed, Won't Do&lt;/strong&gt;, 9 votes — and its description names create, bulk-create, assign and transition. The add-comment endpoint is not in it. It is the nearest-looking ticket to the problem and it is the wrong one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Telling a real parameter from one Jira is only humouring you
&lt;/h2&gt;

&lt;p&gt;When I ran these calls against a live site, every route returned a success code. That tells you nothing on its own. So I sent each endpoint a parameter that cannot exist, and then sent the real parameter a value it cannot accept.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;notifyUsers=false&lt;/th&gt;
&lt;th&gt;notifyUsers=notaboolean&lt;/th&gt;
&lt;th&gt;madeUpParam=notaboolean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;POST /rest/api/3/issue/KEY/comment&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUT /rest/api/3/issue/KEY&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUT /rest/api/3/issue/KEY/comment/ID&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the edit and update-comment endpoints a bad value comes back as &lt;code&gt;Failed to convert 'notifyUsers' with value: 'notaboolean'&lt;/code&gt;. On the add-comment endpoint the same garbage sails through with a 201, exactly like a parameter I invented on the spot. That is the difference between a parameter the endpoint declares and one it has never heard of.&lt;/p&gt;

&lt;p&gt;The same silence applies in the request body. Posting three unknown keys alongside a valid comment:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Extra key in the POST body&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sendNotifications&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;notifyUsers&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;totallyMadeUpKey12345&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All accepted, all ignored, none present on the created comment when I read it back — its keys are exactly &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, &lt;code&gt;created&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;jsdPublic&lt;/code&gt;, &lt;code&gt;self&lt;/code&gt;, &lt;code&gt;updateAuthor&lt;/code&gt; and &lt;code&gt;updated&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this test does not prove
&lt;/h3&gt;

&lt;p&gt;I want to be precise about this, because the obvious conclusion is wrong in two directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 400 fires before the handler runs.&lt;/strong&gt; Send it against a work item that does not exist and you still get a 400 rather than a 404:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PUT /rest/api/3/issue/NOSUCHPROJ-99999?notifyUsers=notaboolean   -&amp;gt; 400
PUT /rest/api/3/issue/NOSUCHPROJ-99999                           -&amp;gt; 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is query-string type binding, not the feature working. A parameter can be declared, type-checked, and still do nothing for you: &lt;code&gt;overrideScreenSecurity&lt;/code&gt; is documented as available to Connect and Forge apps, and sending it &lt;code&gt;notaboolean&lt;/code&gt; as an ordinary API-token caller still returns 400.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it only works on typed values.&lt;/strong&gt; &lt;code&gt;expand&lt;/code&gt; is unquestionably read — ask for &lt;code&gt;expand=renderedBody&lt;/code&gt; and you get a &lt;code&gt;renderedBody&lt;/code&gt; field. Ask for &lt;code&gt;expand=totalgarbage&lt;/code&gt; and you get a cheerful 200 and no complaint, because it is a string and any string is valid.&lt;/p&gt;

&lt;p&gt;So the honest scope: &lt;strong&gt;sending a bad value to a typed parameter tells you whether that parameter is declared on that endpoint.&lt;/strong&gt; It does not tell you the parameter is honoured for your account, and it tells you nothing at all about string parameters. It is a fast way to rule a route out — which is exactly what it does for add-comment — not a way to rule one in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The permission gate is the part that bites
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;notifyUsers=false&lt;/code&gt; requires &lt;strong&gt;Administer Jira&lt;/strong&gt; or &lt;strong&gt;Administer Projects&lt;/strong&gt; on the acting account. Without it the parameter is dropped: the edit still happens, the comment still lands, and the mail still goes out. Nothing fails, nothing warns you.&lt;/p&gt;

&lt;p&gt;Check for both, with project context, or you will get a misleading answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AUTH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/rest/api/3/mypermissions?projectKey=ABC&amp;amp;permissions=ADMINISTER,ADMINISTER_PROJECTS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Asking for &lt;code&gt;ADMINISTER&lt;/code&gt; alone returns only the global permission, so a project administrator without global admin reads &lt;code&gt;false&lt;/code&gt; and wrongly concludes they cannot do this. Either one being true is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it safely
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Look in the rule builder first
if the Edit work item action still offers a notification checkbox and your rule can set a field instead of commenting, use that and stop here&lt;/li&gt;
&lt;li&gt;Check the permission with project context
GET /rest/api/3/mypermissions?projectKey=ABC&amp;amp;permissions=ADMINISTER,ADMINISTER_PROJECTS, and remember a missing permission is dropped silently rather than refused&lt;/li&gt;
&lt;li&gt;Run it on five work items, with yourself watching
add yourself as a watcher, fire the edit-route call, and check your own inbox. This is the only step that tests suppression rather than mechanics&lt;/li&gt;
&lt;li&gt;Take the backfill out of the rule entirely
a one-time catch-up of 85,000 items and a steady-state trickle are different problems; script the backfill against the edit endpoint and let the rule keep handling only new matches&lt;/li&gt;
&lt;li&gt;If you need certainty rather than a workaround, use the notification scheme
empty the recipients on the Issue Commented event for the duration of the backfill, then restore them&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is the unglamorous option and the only one that depends on nothing undocumented. It is reversible, and it suppresses the email because there is nobody left to send it to.&lt;/p&gt;

&lt;p&gt;The event is &lt;strong&gt;Issue Commented&lt;/strong&gt;, event id &lt;code&gt;6&lt;/code&gt;. On the site I checked, its recipients were Current Assignee, Reporter and All Watchers — which is why a backfill across tens of thousands of items becomes tens of thousands of emails.&lt;/p&gt;

&lt;p&gt;Before you edit a scheme, find out which one the project actually uses and how many other projects share it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# which scheme is bound to this project&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AUTH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/rest/api/3/project/ABC/notificationscheme"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool

&lt;span class="c"&gt;# how many projects each scheme is bound to&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AUTH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/rest/api/3/notificationscheme/project?maxResults=50"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the site I tested, the project's scheme was id 10000 and that same scheme was bound to &lt;strong&gt;eleven&lt;/strong&gt; projects. Editing it to quiet one backfill would have silenced comment mail across all eleven. If yours is shared, copy the scheme first and point only the target project at the copy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Emptying a notification-scheme event affects every rule and every human action on that project, not just your backfill. Write down what you removed before you remove it, and put it back the same day.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I verified, and what I did not
&lt;/h2&gt;

&lt;p&gt;I checked the REST mechanics on a live Jira Cloud site: which endpoints declare &lt;code&gt;notifyUsers&lt;/code&gt;, which reject a bad value, which unknown keys are silently swallowed, what survives onto the stored comment, and whether a comment added through the edit route shows up in the issue history. It does not — the changelog stayed empty across every route, so you cannot use history entries to tell the two paths apart.&lt;/p&gt;

&lt;p&gt;I did &lt;strong&gt;not&lt;/strong&gt; verify that the email stopped arriving. No Jira Cloud API reports whether a notification was queued or sent, so the only honest test is a real mailbox, which is step 3 and which you have to run on your own site. Atlassian documents the edit-route workaround and the endpoint type-checks the parameter; those two facts are why I would try it. They are not a measurement, and I am not going to present them as one.&lt;/p&gt;

&lt;p&gt;Three more limits. This is Jira &lt;strong&gt;Cloud&lt;/strong&gt; — the Data Center notification model differs. I tested the REST endpoints rather than the automation rule importer, so the importer explanation remains inference. And the state of the Edit action's notification checkbox is genuinely unresolved: the docs omit it, AUTO-602's opening sentence implies it, and community reports conflict. Check your own instance rather than trusting any of us on that one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wider habit
&lt;/h2&gt;

&lt;p&gt;A 2xx response and a surviving JSON key both feel like confirmation, and neither is. Jira accepts almost anything you send it and quietly uses the parts it recognises.&lt;/p&gt;

&lt;p&gt;The automation rule API does the same thing with a whole object rather than one key. &lt;a href="https://leanzero.net/tutorials/jira-automation-rules-migration-actor-scope?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;A rule created with a scope ARI and a trigger filter that disagree returns 201&lt;/a&gt;, and the trigger you get back is not the trigger you sent — the server rewrote it to agree with the scope, without an error, a warning, or a note in the response.&lt;/p&gt;

&lt;p&gt;So when a flag does not appear to work, stop trying to prove it works and try to prove it is even &lt;em&gt;declared&lt;/em&gt;. Send a typed parameter something it must reject. If nothing complains, you are on a dead end and you have your answer in one call — then go and check the permission, because that is the gate that will actually decide whether it does anything for you.&lt;/p&gt;

&lt;p&gt;That workaround is no longer untested. I &lt;a href="https://leanzero.net/tutorials/jira-notification-test-bed-false-negatives?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;built a bed on a live Cloud tenant and ran it&lt;/a&gt;, and the result is not the clean yes it looks like: every comment route raises the Issue Commented event rather than Issue Updated, which is the event notifyUsers is documented against. The bed itself also turned out to have four separate ways of reporting success while sending no mail at all.&lt;/p&gt;

&lt;p&gt;*The question behind this piece came from a &lt;em&gt;[*thread on the Atlassian Community&lt;/em&gt;](&lt;a href="https://community.atlassian.com/forums/Automation-questions/quot-sendNotifications-false-quot-in-Automation-quot-Add-comment/qaa-p/3269762#M16384)%5B" rel="noopener noreferrer"&gt;https://community.atlassian.com/forums/Automation-questions/quot-sendNotifications-false-quot-in-Automation-quot-Add-comment/qaa-p/3269762#M16384)[&lt;/a&gt;, where the asker had done more homework than most bug reports contain. If you are untangling automation or notification behaviour at scale and want a hand, ](&lt;a href="https://community.atlassian.com/forums/Automation-questions/quot-sendNotifications-false-quot-in-Automation-quot-Add-comment/qaa-p/3269762#M16384)%5Bthat" rel="noopener noreferrer"&gt;https://community.atlassian.com/forums/Automation-questions/quot-sendNotifications-false-quot-in-Automation-quot-Add-comment/qaa-p/3269762#M16384)[that&lt;/a&gt; is the kind of work we do](&lt;a href="https://leanzero.net/contact?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost)%5B.%5D(https://leanzero.net/contact?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;https://leanzero.net/contact?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost)[.](https://leanzero.net/contact?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost&lt;/a&gt;)&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/tutorials/suppressing-the-email-when-jira-automation-adds-a-comment-and-why-sendnotifications-does-nothing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>jira</category>
      <category>atlassian</category>
      <category>automation</category>
      <category>api</category>
    </item>
    <item>
      <title>Repair the Confluence Cloud pages your migration quietly broke, one space at a time</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Sat, 03 Oct 2026 20:45:25 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/repair-the-confluence-cloud-pages-your-migration-quietly-broke-one-space-at-a-time-27jg</link>
      <guid>https://dev.to/mihai_leanzero/repair-the-confluence-cloud-pages-your-migration-quietly-broke-one-space-at-a-time-27jg</guid>
      <description>&lt;h1&gt;
  
  
  Repair the Confluence Cloud pages your migration quietly broke, one space at a time
&lt;/h1&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;END STATE: one space repaired, with a reviewed JSON plan, a backup of every original page, and the fix confirmed in a browser rather than in a 200 response.&lt;/li&gt;
&lt;li&gt;Site admin is NOT space admin. Grant yourself space admin first or the run collects 403s for an hour.&lt;/li&gt;
&lt;li&gt;A storage-format rewrite that is syntactically valid and semantically wrong writes successfully and reports success. The API cannot tell you it is wrong; only the rendered page can.&lt;/li&gt;
&lt;li&gt;Discovery is CQL, so it is only as good as the index. Dump the raw storage XML of a few matches on YOUR tenant before a full run — storage format varies by app version.&lt;/li&gt;
&lt;li&gt;Restricted pages block app-data migrations. Back the restrictions up, strip them for the run, restore them on Cloud with usernames resolved to accountIds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration report said the pages moved. The pages did move. Then someone opened a runbook that had a formatted table in it and found a grey box reading &lt;em&gt;Unknown macro&lt;/em&gt;, and the question arrived: what else looks like that, and how would we know?&lt;/p&gt;

&lt;p&gt;A Confluence Data Center to Cloud migration moves page content. It does not guarantee that what is &lt;em&gt;inside&lt;/em&gt; the page still works, and the things that break do not appear in the migration report because from the migration's point of view nothing failed. The bytes arrived.&lt;/p&gt;

&lt;p&gt;This walks through repairing them on one space, with the two habits that make it safe: a plan you read before anything is written, and a check that happens in a browser rather than in an HTTP status code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Prerequisites&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Node 18 or later. Verified on v24.15.0.&lt;/p&gt;

&lt;p&gt;A Confluence Cloud API token from id.atlassian.com → Security → API tokens, used as Basic auth with your account email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Space admin on every space you intend to edit. Site admin is not enough&lt;/strong&gt; — this is the step everyone skips, and step 1 exists entirely to fix it.&lt;/p&gt;

&lt;p&gt;Read access to the source Data Center instance for the tools that compare against it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://leanzero.net/portfolio/confluence-migration-toolkit?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Confluence Migration Toolkit&lt;/a&gt;, Apache-2.0 and free. Seven tools, no dependencies beyond &lt;code&gt;dotenv&lt;/code&gt; and a parser where XHTML genuinely has to be parsed.&lt;/p&gt;

&lt;p&gt;About an hour for one space.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;Grant yourself space admin, because site admin does not include it.&lt;/li&gt;
&lt;li&gt;Dump the raw storage XML of a few matches before trusting any discovery query.&lt;/li&gt;
&lt;li&gt;Build a plan and read it
every tool writes one before it writes anything else.&lt;/li&gt;
&lt;li&gt;Repair the broken HTML macros, choosing the replacement mode deliberately.&lt;/li&gt;
&lt;li&gt;Flatten the nested macros that Cloud's editor refuses to open.&lt;/li&gt;
&lt;li&gt;Re-resolve the identity parameters that still hold DC usernames.&lt;/li&gt;
&lt;li&gt;Back up, strip and restore page restrictions around an app migration.&lt;/li&gt;
&lt;li&gt;Verify on the rendered page, which is the only check that can fail honestly.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1 — Grant yourself space admin
&lt;/h2&gt;

&lt;p&gt;Confluence Cloud scopes administration per space, and a site admin is not automatically a space admin. A site admin can see a space without being able to edit every page in it, which means a script authenticating as you will authenticate perfectly and then be refused on the write.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;grant-space-admin
npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env     &lt;span class="c"&gt;# CLOUD_BASE_URL (include /wiki), CLOUD_EMAIL, CLOUD_API_TOKEN&lt;/span&gt;
node main/grant_space_admin.js &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; the help text should print the flags below, and the dry run should list the spaces it would touch without granting anything. Confirm &lt;code&gt;--dry-run&lt;/code&gt; is present before you run anything without it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Grant Space Admin — Confluence Cloud

Adds the current API-token user as an administrator on every Confluence Cloud
space (or a subset via --space). Additive only: existing permissions are
preserved. Idempotent.

Usage:
  node main/grant_space_admin.js [options]

Options:
  --dry-run              Preview without granting
  --space &amp;lt;KEY&amp;gt;          Restrict to specific space(s), repeatable or comma-sep
  --skip-personal        Exclude personal spaces (keys starting with ~)
  --full-grant           Grant full admin-equivalent permission set
                         (otherwise just administer:space + read:space)
  --concurrency &amp;lt;N&amp;gt;      Parallel workers (default: 3)
  --help                 Show this help
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two words in that output matter more than the rest. &lt;strong&gt;Additive&lt;/strong&gt; means existing permissions are preserved rather than replaced, so running it does not quietly become a permissions rewrite. &lt;strong&gt;Idempotent&lt;/strong&gt; means a second run is not a second grant. Both are properties you should confirm in any tool you point at a permission scheme, because the failure mode of getting either wrong is one you discover from a colleague rather than from a log.&lt;/p&gt;

&lt;p&gt;Start with &lt;code&gt;--space&lt;/code&gt; on a single key. There is no reason for your first run to be site-wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Dump the raw storage XML before you trust discovery
&lt;/h2&gt;

&lt;p&gt;Every one of these tools finds its work with CQL, which means discovery is exactly as good as the search index and as your assumption about what the macro looks like in storage format. That assumption is the part that breaks, because &lt;strong&gt;storage format varies by app version&lt;/strong&gt;. The macro you are searching for may be named something slightly different on your tenant than on the one a tool was written against.&lt;/p&gt;

&lt;p&gt;So before a full run, print the raw storage XML of a handful of matches and read it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node main/sync_html_macros.js &lt;span class="nt"&gt;--space&lt;/span&gt; ENG &lt;span class="nt"&gt;--limit&lt;/span&gt; 5 &lt;span class="nt"&gt;--plan-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; you should see a plan file written under &lt;code&gt;logs/&lt;/code&gt; and a match count that is plausible for the space. If it reports zero, do not conclude the space is clean — conclude the query did not match, and go and look at a page you know is broken to see what its storage format actually contains. A count of zero and a genuinely clean space are indistinguishable from the outside, and only one of them is good news.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Build a plan and read it
&lt;/h2&gt;

&lt;p&gt;Every tool here runs in two phases, and the split is the safety property, not a convenience. A read-only &lt;strong&gt;plan&lt;/strong&gt; phase discovers affected pages and writes a reviewable JSON file. An &lt;strong&gt;execute&lt;/strong&gt; phase acts on that file. &lt;code&gt;--dry-run&lt;/code&gt; runs the execute phase with the &lt;code&gt;PUT&lt;/code&gt; suppressed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node main/sync_html_macros.js &lt;span class="nt"&gt;--space&lt;/span&gt; ENG &lt;span class="nt"&gt;--plan-only&lt;/span&gt;
&lt;span class="c"&gt;# read the plan&lt;/span&gt;
node main/sync_html_macros.js &lt;span class="nt"&gt;--space&lt;/span&gt; ENG &lt;span class="nt"&gt;--execute-only&lt;/span&gt; &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan is a file you can open, grep and diff. That matters because the failure this catches is not a crash — it is a plan that is internally consistent and wrong, which executes beautifully and produces the wrong pages. You cannot catch that by watching a progress bar; you catch it by reading twenty lines of JSON and noticing that a page you know well is about to be rewritten in a way you did not expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; the dry run should report the same page count as the plan and write nothing. If the counts differ, the plan is stale — rebuild it rather than reasoning about the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Repair the broken HTML macros
&lt;/h2&gt;

&lt;p&gt;This is the common case. An HTML or CSS macro whose Cloud build differs, or does not exist at all, leaves either an &lt;em&gt;Unknown macro&lt;/em&gt; placeholder or the original XML sitting in page storage, unrendered and unreachable.&lt;/p&gt;

&lt;p&gt;The repair extracts the raw content from the DC page's storage format and replaces the broken block on Cloud. There are three modes and choosing the wrong one is the main way this goes wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  "raw"   - Replace the entire macro block with inline content (default).
            Use when the macro app is NOT installed in Cloud.
  "macro" - Preserve the ac:structured-macro wrapper but fix the CDATA content.
            Use when the macro app IS installed in Cloud but content is wrong.
  "code"  - Wrap DC content in a Code macro block (preserves HTML/CSS via CDATA).
            Use when raw HTML gets stripped by Cloud's storage sanitizer.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That third mode is worth understanding before you need it. Cloud runs a storage sanitiser on write, and raw HTML pushed into a page can be stripped by it — which is precisely why the &lt;code&gt;code&lt;/code&gt; mode exists, wrapping the content in a Code macro whose CDATA survives. The consequence to plan around is that &lt;strong&gt;the request succeeding tells you the write was accepted, not that the content is still there.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node main/sync_html_macros.js &lt;span class="nt"&gt;--space&lt;/span&gt; ENG &lt;span class="nt"&gt;--replacement-mode&lt;/span&gt; raw &lt;span class="nt"&gt;--limit&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; run it against exactly one page, then open that page in a browser. Not the API response — the page. A rewrite that is syntactically valid and semantically wrong writes successfully and reports success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Flatten the nested macros
&lt;/h2&gt;

&lt;p&gt;Nested bodied macros are legal on Data Center and unsupported by Cloud's Fabric editor. The symptom is distinctive: the page renders wrong &lt;em&gt;and&lt;/em&gt; the editor refuses to open it, so the obvious repair — fix it by hand — is unavailable exactly where you need it.&lt;/p&gt;

&lt;p&gt;The fix rewrites the structure so the previously-nested macro becomes a sibling instead of a child, using a split-around-child strategy that handles arbitrary nesting depth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; open the page in the &lt;em&gt;editor&lt;/em&gt;, not just the viewer. The viewer may render a page the editor still refuses. Editing is the capability you lost, so editing is the capability to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Re-resolve the identity parameters
&lt;/h2&gt;

&lt;p&gt;Anything that stored a DC username or group name stops resolving on Cloud, because Cloud is keyed on &lt;code&gt;accountId&lt;/code&gt; and &lt;code&gt;groupId&lt;/code&gt;. &lt;em&gt;Show If&lt;/em&gt; and &lt;em&gt;Hide If&lt;/em&gt; visibility macros are the ones that bite. The documented behaviour is that they &lt;strong&gt;stop showing protected content&lt;/strong&gt; — the parameters still name DC usernames and group names, the Cloud build of the app expects ids, the condition no longer matches anyone, and content that was meant to be visible to a group becomes visible to nobody.&lt;/p&gt;

&lt;p&gt;Note which direction that is, because it is the recoverable one. Content that has gone missing gets reported by the person who needed it. I have not established what happens in the opposite direction and I am not going to guess at it here — but the reason to test both is that a positive-only test cannot tell the two apart.&lt;/p&gt;

&lt;p&gt;The repair resolves the stored &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;user-groups&lt;/code&gt; parameters against Cloud and rewrites them to ids.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; pick a page whose visibility macro names a group you belong to, and confirm the protected content is visible to you again. Then check a page restricted to a group you are &lt;em&gt;not&lt;/em&gt; in and confirm it stays hidden. Testing only the first case cannot distinguish a repaired macro from one that has stopped filtering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — Back up, strip and restore page restrictions
&lt;/h2&gt;

&lt;p&gt;App-data migrations fail on restricted content, and the error is specific enough to search for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Some data are still not migrated. If these space(s) contain restricted pages,
please run the migration script provided.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause is that the migration runs as an app user which page restrictions exclude. The restrictions are the blocker, and the only reliable fix is to remove them for the duration and put them back afterwards.&lt;/p&gt;

&lt;p&gt;Three scripts, in order, and the middle one is the one to be careful with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node check_pages.js                    &lt;span class="c"&gt;# DC, read-only: what restrictions exist&lt;/span&gt;
node remove_restrictions_dc.js         &lt;span class="c"&gt;# DC: full backup JSON, THEN remove&lt;/span&gt;
node restore_restrictions_cloud.js     &lt;span class="c"&gt;# Cloud: re-apply, resolving usernames to accountIds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; &lt;code&gt;check_pages.js&lt;/code&gt; is read-only and exists to be run first — it reports whether each content id exists and what restrictions it carries. Run it before and after the whole cycle and compare. The backup JSON written by the remove step is your undo, and it is the only one; if that file is lost between stripping and restoring, the restrictions are gone and nobody will notice until the wrong person opens the wrong page.&lt;/p&gt;

&lt;p&gt;Do not run the remove step on a Friday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8 — Verify on the rendered page
&lt;/h2&gt;

&lt;p&gt;Every check in this article has pushed toward the same place, so it is worth saying plainly.&lt;/p&gt;

&lt;p&gt;A storage-format rewrite goes through Confluence's API. The API validates that your XHTML is well-formed. It does not, and cannot, validate that the page still means what it meant — that the table still has its columns, that the macro still has its body, that the content a reader needs is still on the page rather than stripped by a sanitiser. &lt;strong&gt;Syntactically valid and semantically wrong writes successfully and reports success.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the verification is not a status code and not a count. Run every tool against one space, then one page, and open the result in a browser before you let it near the rest of the site. That is slower for the first page and very much faster than discovering three weeks later that four hundred pages have an empty box where a runbook used to be.&lt;/p&gt;

&lt;p&gt;The same instinct applies on the Jira side of a migration, where &lt;a href="https://leanzero.net/blog/migrated-workflow-logic-does-not-look-broken?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;logic that migrated in form but not in meaning&lt;/a&gt; fails the same silent way — and it belongs on &lt;a href="https://leanzero.net/tutorials/migration-gap-list-before-you-start?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;the gap list you build before you start&lt;/a&gt; rather than being discovered afterwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Grant space admin before anything else. Site admin does not include it, and without it the tools authenticate correctly and then collect 403s on every write.&lt;/li&gt;
&lt;li&gt;Confirm the storage format on YOUR tenant. Discovery is CQL and storage format varies by app version, so a zero-match result means "the query did not match", not "the space is clean".&lt;/li&gt;
&lt;li&gt;Read the plan. The dangerous failure is not a crash — it is a plan that is internally consistent and wrong, which executes perfectly and produces the wrong pages.&lt;/li&gt;
&lt;li&gt;Choose the replacement mode on purpose. Cloud sanitises storage on write, so raw HTML can be accepted, stripped and stored as an empty page with a 200 response. The code mode exists for exactly that.&lt;/li&gt;
&lt;li&gt;Verify in a browser, and test both directions. A positive-only check cannot tell a repaired visibility macro from one that has stopped filtering. The API cannot tell you a page is semantically wrong; only the page can.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/tutorials/repair-confluence-cloud-after-dc-migration?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>confluence</category>
      <category>atlassian</category>
      <category>migration</category>
      <category>bash</category>
    </item>
    <item>
      <title>Skip the migration writes that change nothing, without skipping the ones that matter</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Sat, 03 Oct 2026 20:43:44 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/skip-the-migration-writes-that-change-nothing-without-skipping-the-ones-that-matter-3on9</link>
      <guid>https://dev.to/mihai_leanzero/skip-the-migration-writes-that-change-nothing-without-skipping-the-ones-that-matter-3on9</guid>
      <description>&lt;h1&gt;
  
  
  Skip the migration writes that change nothing, without skipping the ones that matter
&lt;/h1&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;END STATE: a re-run that writes only what differs, with a false-SAME test suite you run BEFORE trusting it with a write.&lt;/li&gt;
&lt;li&gt;Our canonicaliser nulled any object holding an empty &lt;code&gt;text&lt;/code&gt; key. A mention's attrs carries one, so attrs.id vanished and two DIFFERENT users hashed the same. Fixed by guarding on node type.&lt;/li&gt;
&lt;li&gt;Hash the PLANNED value against the destination. Source-against-destination never matches for any entity your pipeline rewrites.&lt;/li&gt;
&lt;li&gt;Both hashers collapse whitespace inside code — a mangled Python indent will be skipped as a no-op. Exclude code bodies.&lt;/li&gt;
&lt;li&gt;Pick the hasher by BODY FORMAT, not by product: Confluence v2 defaults to atlas_doc_format, which is ADF.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Re-running a migration script should be cheap. Usually it is not: the second run rewrites every entity, bumps every version number, and spends the same rate-limit budget to produce bytes that are already there.&lt;/p&gt;

&lt;p&gt;The fix is small — hash what you are about to write, hash what is there, skip when they match. I wrote that up, then went looking for cases where the hash is wrong, and found one that would have skipped writes it should have made. So this is the pattern &lt;em&gt;and&lt;/em&gt; the test suite, because the second is what makes the first safe.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Prerequisites&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Node 18 or later. Verified on v24.15.0.&lt;/p&gt;

&lt;p&gt;Two hashers from the migration toolkit: &lt;code&gt;semanticHash&lt;/code&gt; in &lt;code&gt;adf-builders.js&lt;/code&gt; for ADF, and &lt;code&gt;BackupManager.storageHash&lt;/code&gt; in &lt;code&gt;backup-manager.js&lt;/code&gt; for Confluence storage XHTML. Note the second is a &lt;strong&gt;static method&lt;/strong&gt;, not a bare export.&lt;/p&gt;

&lt;p&gt;No tenant, no credentials, no deploy. Everything runs locally against literals, which is the point: you want this proven before it decides whether to skip a production write.&lt;/p&gt;

&lt;p&gt;About thirty minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;Write the harness so every claim below is a command you can run.&lt;/li&gt;
&lt;li&gt;Prove it ignores cosmetic difference, and still catches a real edit.&lt;/li&gt;
&lt;li&gt;Hunt for a false SAME, which is the failure that loses data.&lt;/li&gt;
&lt;li&gt;Compare the planned value against the destination, never the source.&lt;/li&gt;
&lt;li&gt;Check what a Cloud-to-Cloud move preserves and what it does not.&lt;/li&gt;
&lt;li&gt;Pick the hasher by body format, not by product name.&lt;/li&gt;
&lt;li&gt;Record the skip so an audit can tell it from an entity you never reached.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1 — Write the harness
&lt;/h2&gt;

&lt;p&gt;Every output block below comes from this file. Save it as &lt;code&gt;check.js&lt;/code&gt; beside the two templates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;adf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./adf-builders.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BackupManager&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./backup-manager.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;doc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paragraph&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;adf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;semanticHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;same&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;h&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;h&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;same&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok  &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;!!  &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
              &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;same&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAME&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DIFF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;   want &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;want&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAME&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DIFF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;adf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BackupManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; &lt;code&gt;node -e "require('./check.js'); console.log('harness ok')"&lt;/code&gt; should print &lt;code&gt;harness ok&lt;/code&gt; and nothing else. If it throws on &lt;code&gt;BackupManager&lt;/code&gt;, you destructured a bare export that does not exist — &lt;code&gt;storageHash&lt;/code&gt; hangs off the class.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Prove both directions
&lt;/h2&gt;

&lt;p&gt;A hash that returns SAME for everything is worse than no hash, because it silently stops migrating. So run the negative control in the same breath as the positive one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node check-cosmetic.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== should be SAME — cosmetic difference only ===
  ok  identical documents                         SAME   want SAME
  ok  collapsed whitespace                        SAME   want SAME
  ok  leading/trailing space                      SAME   want SAME
  ok  empty marks array present                   SAME   want SAME
  ok  key order differs                           SAME   want SAME

=== should be DIFFERENT — a real edit ===
  ok  one word changed                            DIFF   want DIFF
  ok  case changed                                DIFF   want DIFF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; every line should start &lt;code&gt;ok&lt;/code&gt;. Case matters — the canonicaliser collapses whitespace but does not lower-case, which is right for prose and wrong for URLs, where scheme and host are case-insensitive. If you hash links, know that &lt;code&gt;HTTPS://…&lt;/code&gt; and &lt;code&gt;https://…&lt;/code&gt; will differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Hunt for a false SAME
&lt;/h2&gt;

&lt;p&gt;This is the step that matters, and it is the one I nearly skipped. A false DIFFERENT costs you rate-limit points. A &lt;strong&gt;false SAME skips a write that should have happened&lt;/strong&gt;, and nothing anywhere reports it.&lt;/p&gt;

&lt;p&gt;Ours had one, in the code that had been in production for months:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  raw mention A     : {"type":"mention","attrs":{"id":"accountid-AAAA","text":"","accessLevel":""}}
  raw mention B     : {"type":"mention","attrs":{"id":"accountid-BBBB","text":"","accessLevel":""}}
  canonical A       : {"attrs":null,"type":"mention"}
  canonical B       : {"attrs":null,"type":"mention"}
  semanticHash equal: true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two mentions of &lt;strong&gt;different people&lt;/strong&gt;, hashing identically. The canonicaliser had this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// &amp;lt;- nulls the ENCLOSING OBJECT&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent was to drop empty text nodes. What it actually did was null &lt;em&gt;any object holding an empty *`*text&lt;/em&gt;&lt;code&gt;* key* — and a mention's &lt;/code&gt;attrs&lt;code&gt; holds one. [Atlassian's ADF spec](https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/mention/) makes &lt;/code&gt;attrs.text&lt;code&gt; optional while &lt;/code&gt;attrs.id&lt;code&gt; is required, and our own &lt;/code&gt;mention(accountId)&lt;code&gt; helper emits &lt;/code&gt;text: ""&lt;code&gt; when no display name is passed. So &lt;/code&gt;attrs&lt;code&gt; was nulled, taking &lt;/code&gt;attrs.id` with it, before anything was compared. Emoji and status nodes have the same shape and were annihilated the same way.&lt;/p&gt;

&lt;p&gt;Play that forward in a migration. Run one writes a wrong accountId. You spot it, fix the mapping, re-run — and the hash reports the destination already matches. The write is skipped, the plan records success, the audit is clean, and the wrong user stays there forever.&lt;/p&gt;

&lt;p&gt;The fix is to guard on the node type rather than the key name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isTextNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;isTextNode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// an empty TEXT NODE is nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; after the fix, two mentions with different ids should print DIFF, and all seven rows from step 2 should still print &lt;code&gt;ok&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  two users, empty attrs.text:  DIFFERENT   fixed
  built by adf.mention():       DIFFERENT   fixed
  emoji  :A: vs :B:             DIFFERENT   fixed
  status Done vs Todo           DIFFERENT   fixed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;And do not read that as the only one.&lt;/strong&gt; It is the one I found. Two others I can characterise: JavaScript's &lt;code&gt;\s&lt;/code&gt; matches a non-breaking space, so text differing only by &lt;code&gt;&amp;nbsp;&lt;/code&gt; is skipped; and both hashers collapse whitespace &lt;em&gt;inside code&lt;/em&gt;, so a migration meant to repair mangled Python or YAML indentation will treat it as a no-op. Exclude code bodies from the hash. I have not enumerated the rest, and you should not assume the list is closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Compare the planned value, not the source
&lt;/h2&gt;

&lt;p&gt;The tempting comparison is &lt;em&gt;source&lt;/em&gt; against &lt;em&gt;destination&lt;/em&gt;. For any entity your pipeline rewrites — which is the point of a migration — those never match, so nothing is ever skipped and you have added a read per entity for no saving. (For an entity the pipeline leaves alone, they do match; that is the exception, not the rule.)&lt;/p&gt;

&lt;p&gt;The correct comparison is &lt;em&gt;planned&lt;/em&gt; against &lt;em&gt;destination&lt;/em&gt;. It is the same ordering discipline as &lt;a href="https://leanzero.net/blog/migration-plan-wrong-tenant-fingerprint?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;stamping a plan with the tenant it was built for&lt;/a&gt; — a check earns its read only when it compares the two things that can actually differ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;planned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceDoc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;jira&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getIssue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;adf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;semanticHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;planned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;adf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;semanticHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// skip — see step 7 for how to record it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked&lt;/strong&gt; — the two orderings should give opposite answers on the same pair:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  planned-for-B vs live-on-B : SAME  -&amp;gt; correctly skips
  source-on-A  vs live-on-B  : DIFFERENT -&amp;gt; would rewrite forever
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One guard before you ship it: &lt;code&gt;semanticHash(null) === semanticHash(undefined)&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, so a &lt;code&gt;rewrite()&lt;/code&gt; that returns undefined against an empty destination field skips and reports success. Assert your planned value is a document before you hash it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Check what a Cloud-to-Cloud move preserves
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On Cloud-to-Cloud, a mention with a stable **`&lt;/strong&gt;accountId*&lt;em&gt;`&lt;/em&gt;* and the same cached display name hashes the same on both tenants.** The accountId belongs to the Atlassian account rather than the site, so it survives the move:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  same accountId + same text          SAME  (skip)
  same accountId, display name differs DIFFERENT (write)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second row is worth knowing: &lt;code&gt;attrs.text&lt;/code&gt; is hashed too, so a cached display name that changed between tenants writes even when the identity did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data Center is the opposite case.&lt;/strong&gt; The user identifier is remapped as part of the move, so a source-versus-destination hash can never match for any mention-bearing document. Note the source representation differs by product — Confluence stores an opaque per-instance key, &lt;code&gt;&amp;lt;ri:user ri:userkey="2c9680f7405147ee0140514c26120003"/&amp;gt;&lt;/code&gt;, which &lt;a href="https://confluence.atlassian.com/doc/confluence-storage-format-790796544.html" rel="noopener noreferrer"&gt;the storage format reference&lt;/a&gt; calls "the unique identifier of the user". It is not a username, and it means nothing on the destination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; on a Cloud-to-Cloud plan you should see a meaningful skip rate on mention-bearing content and near zero on anything embedding the old tenant hostname. On a Data Center plan you should see near zero either way until you switch to planned-versus-destination — which is step 4, and on DC it is not an optimisation but the only way the check works at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Pick the hasher by body format
&lt;/h2&gt;

&lt;p&gt;The rule is not "Confluence uses &lt;code&gt;storageHash&lt;/code&gt;". It is: &lt;strong&gt;hash the format you actually fetched.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Confluence Cloud's v2 API defaults to &lt;code&gt;atlas_doc_format&lt;/code&gt;, which is ADF — our own client signature is &lt;code&gt;getPageByIdV2(pageId, bodyFormat = "atlas_doc_format")&lt;/code&gt;, and the toolkit's notes call it "ADF JSON. Same as Jira's. The new default." Fetch a v2 page with defaults and hand it to &lt;code&gt;storageHash&lt;/code&gt; and you get a string hash of serialised JSON, which sorts no keys and skips nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Two semantically IDENTICAL Confluence ADF bodies, different JSON key order:
  storageHash  : DIFFERENT   &amp;lt;- never skips
  semanticHash : SAME        &amp;lt;- correct
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;storageHash&lt;/code&gt; only when you asked for &lt;code&gt;body-format=storage&lt;/code&gt;. It normalises the three things that change XHTML bytes without changing the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// inter-tag whitespace&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// self-closing form&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// runs of whitespace — including inside &amp;lt;pre&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that first rule closely, because it is narrower than it looks: it collapses whitespace &lt;strong&gt;between tags&lt;/strong&gt;, where a &lt;code&gt;&amp;gt;&lt;/code&gt; is followed by a &lt;code&gt;&amp;lt;&lt;/code&gt;. Indentation wrapped around &lt;em&gt;text&lt;/em&gt; is a different case. &lt;code&gt;\s+&lt;/code&gt; becomes a single space rather than nothing, and nothing trims, so &lt;code&gt;&amp;lt;p&amp;gt;hello&amp;lt;/p&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;p&amp;gt;\n hello\n&amp;lt;/p&amp;gt;&lt;/code&gt; canonicalise to &lt;code&gt;&amp;lt;p&amp;gt;hello&amp;lt;/p&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;p&amp;gt; hello &amp;lt;/p&amp;gt;&lt;/code&gt; — and hash differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; three SAME and two DIFFERENT.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== Confluence storage XHTML ===
  identical                                    SAME  (skip)
  reflowed whitespace                          SAME  (skip)
  indentation BETWEEN tags                     SAME  (skip)
  indentation around TEXT in a tag             DIFFERENT (write)
  real edit                                    DIFFERENT (write)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fourth row is the harmless direction — a false DIFFERENT costs you a write you did not need, not data. Worth knowing anyway if your source was ever pretty-printed, because it will quietly cost you the whole saving on exactly the content you hoped to skip. And note the third rule is the code-indentation trap from step 3 — it does not stop at markup.&lt;/p&gt;

&lt;p&gt;One more thing to expect: &lt;code&gt;localId&lt;/code&gt; on panels and task lists, and &lt;code&gt;ac:macro-id&lt;/code&gt; on Confluence macros, are stamped per instance and are hashed. On macro-heavy content the skip rate collapses and that is the reason — worth putting on the &lt;a href="https://leanzero.net/tutorials/migration-gap-list-before-you-start?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;gap list before you start&lt;/a&gt; rather than discovering it mid-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — Record the skip
&lt;/h2&gt;

&lt;p&gt;The signature is &lt;code&gt;updateEntryStatus(entryId, status, error = null)&lt;/code&gt; — that third argument is the &lt;strong&gt;error&lt;/strong&gt; field, so passing a reason there marks every successful skip as an error. Put the reason somewhere it belongs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;planManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateEntryStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;planManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patchEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;skipReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;destination already matches&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; after a second run over unchanged data, every entry should read &lt;code&gt;status: "skipped"&lt;/code&gt; with &lt;code&gt;error: null&lt;/code&gt; and a populated &lt;code&gt;skipReason&lt;/code&gt;. A silently-returned skip leaves &lt;code&gt;{"status":"pending","error":null}&lt;/code&gt; — byte-identical to an entity the script never reached, and it will be handed back to you again on every future run.&lt;/p&gt;

&lt;p&gt;A semantic hash makes a re-run cheap: canonicalise, hash, skip the writes that would produce bytes already present. Compare the &lt;strong&gt;planned&lt;/strong&gt; value against the destination, never the source, because rewriting is the job.&lt;/p&gt;

&lt;p&gt;The part I would carry to any hash you write yourself: &lt;strong&gt;the false SAME is the one that costs you.&lt;/strong&gt; Ours nulled any object holding an empty &lt;code&gt;text&lt;/code&gt; key, which erased a mention's &lt;code&gt;accountId&lt;/code&gt; — so two different users hashed alike and a corrected user mapping would have been skipped as "already matches", with a clean audit. It is fixed by guarding on the node type, and it existed because the canonicaliser was tested for what it should ignore and never for what it must not.&lt;/p&gt;

&lt;p&gt;Test both directions before a hash is allowed to skip a write. Exclude code bodies, since both hashers collapse whitespace inside them. Choose the hasher by the body format you fetched rather than by the product. And record the skip in the plan, because an audit cannot tell a silent skip from an entity you never reached.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/tutorials/migration-semantic-hash-skip-noop-writes?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>atlassian</category>
      <category>migration</category>
      <category>confluence</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Build your migration's gap list before you start, and make the differ refuse to guess</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Sat, 03 Oct 2026 20:42:03 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/build-your-migrations-gap-list-before-you-start-and-make-the-differ-refuse-to-guess-37pn</link>
      <guid>https://dev.to/mihai_leanzero/build-your-migrations-gap-list-before-you-start-and-make-the-differ-refuse-to-guess-37pn</guid>
      <description>&lt;h1&gt;
  
  
  Build your migration's gap list before you start, and make the differ refuse to guess
&lt;/h1&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;END STATE: a list of what your migration will NOT carry, produced from your own inventory before day one, with partials called out separately.&lt;/li&gt;
&lt;li&gt;Coverage is THREE-state, not two. Custom fields are copied by type; workflows only when linked to a project. A boolean set hides both.&lt;/li&gt;
&lt;li&gt;Identical config names on two tenants are different entities. Remap on id, and refuse to map a name that is ambiguous at the destination.&lt;/li&gt;
&lt;li&gt;The tooling appends '(migrated)' when it resolves a clash, so a name-keyed differ reports those as missing unless you normalise.&lt;/li&gt;
&lt;li&gt;Different tools per path: JCMA for Jira and JSM, CCMA for Confluence, Data transfer for Cloud to Cloud.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every Atlassian migration carries most of your data and leaves a remainder. The remainder is published, it differs per path, and almost nobody writes it down before starting.&lt;/p&gt;

&lt;p&gt;This builds two artefacts from your own instance: a list of what the tool will not carry, and an id remap table that refuses to guess. The second one matters more than it sounds.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Prerequisites&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Node 18 or later. Verified on v24.15.0.&lt;/p&gt;

&lt;p&gt;Read access to your source instance, and to the destination if you have one.&lt;/p&gt;

&lt;p&gt;An empty directory. Everything here runs offline — you export an inventory and diff it locally, so nothing writes to a tenant.&lt;/p&gt;

&lt;p&gt;Know which path you are on, because the tools differ: &lt;strong&gt;Jira/JSM Data Center → Cloud&lt;/strong&gt; uses the Jira Cloud Migration Assistant, &lt;strong&gt;Confluence Data Center → Cloud&lt;/strong&gt; uses the separate Confluence Cloud Migration Assistant, and &lt;strong&gt;Cloud → Cloud&lt;/strong&gt; uses &lt;strong&gt;Data transfer&lt;/strong&gt; (the docs still carry its old name, Copy product data).&lt;/p&gt;

&lt;p&gt;About forty minutes, most of it reading the copied-data page properly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The insight that makes this worth doing
&lt;/h2&gt;

&lt;p&gt;The obvious version of this exercise is a set difference: list what the tool copies, subtract it from what you have, and the remainder is your build.&lt;/p&gt;

&lt;p&gt;That is wrong, and it is wrong in the direction that hurts. Atlassian's page does not say yes or no per kind. It says things like custom fields are copied — "Text, Date, Number, Time, Labels, URL, Select list…" — while "Project picker, Affected services, Responders" are not. It says "Workflows and permission schemes (that are not linked to any project won't be migrated)".&lt;/p&gt;

&lt;p&gt;Those are &lt;strong&gt;partials&lt;/strong&gt;. A boolean set filters them out as done, and you find out at cutover. So the coverage model here is three-state: carried, partial, not carried — and partials appear in the output as work, with the caveat attached.&lt;/p&gt;

&lt;p&gt;I got this wrong myself. An earlier version of this used a boolean set, and its own worked example listed 19 dashboards as a rebuild — dashboards are copied. The tool was confidently inventing work.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Encode the copied-data page as a three-state coverage map for your path.&lt;/li&gt;
&lt;li&gt;Export an inventory of your source instance by kind, as JSON.&lt;/li&gt;
&lt;li&gt;Compute the gap list, with partials reported separately from outright gaps.&lt;/li&gt;
&lt;li&gt;Diff the tenant-scoped config on id, normalising the "(migrated)" suffix.&lt;/li&gt;
&lt;li&gt;Prove the differ exits non-zero on an ambiguous name.&lt;/li&gt;
&lt;li&gt;Assign an owner to every line, which is what turns a list into scope.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1 — Encode the page as three states
&lt;/h2&gt;

&lt;p&gt;Open the current copied-data page for your path and read it properly. Save this as &lt;code&gt;gaps.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;carried&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PARTIAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;partial&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NOT_CARRIED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COVERAGE_C2C&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;spaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;screens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request-types&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;queues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sla-calendars&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer-organizations&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dashboards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;boards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;custom-fields&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="nx"&gt;PARTIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// named types only; project picker/responders excluded&lt;/span&gt;
  &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="nx"&gt;PARTIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// only when linked to a project&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;permission-schemes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PARTIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// only when linked to a project&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;knowledge-bases&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;PARTIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// the link copies; article content needs its own handling&lt;/span&gt;

  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;automation-rules&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;NOT_CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;global-permissions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NOT_CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="nx"&gt;NOT_CARRIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That map is Cloud → Cloud. On the DC path, build the equivalent from your assistant's own "what gets migrated" page — &lt;a href="https://support.atlassian.com/migration/docs/what-gets-migrated-with-the-jira-cloud-migration-assistant/" rel="noopener noreferrer"&gt;JCMA's&lt;/a&gt; for Jira and JSM, &lt;a href="https://support.atlassian.com/migration/docs/what-migrates-with-the-confluence-cloud-migration-assistant/" rel="noopener noreferrer"&gt;CCMA's&lt;/a&gt; for Confluence. The members are different; the method is identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; count the states and confirm none is empty.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"import('./gaps.mjs').then(m=&amp;gt;{const v=Object.values(m.COVERAGE_C2C);
  console.log('carried',v.filter(x=&amp;gt;x==='carried').length,
              'partial',v.filter(x=&amp;gt;x==='partial').length,
              'not',v.filter(x=&amp;gt;x==='not').length);})"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see all three counts above zero. A zero in &lt;code&gt;partial&lt;/code&gt; means you read the page as a yes/no table, which is the mistake this whole step exists to prevent — go back and look for the words "some", "only", and any bracketed exclusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Export a source inventory
&lt;/h2&gt;

&lt;p&gt;You need counts by kind. An array of identifiers or a plain number both work; anything else is refused rather than silently miscounted.&lt;/p&gt;

&lt;p&gt;Save it as &lt;code&gt;inv.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"projects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"workflows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"custom-fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"automation-rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"global-permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"app-data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dashboards"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the kind names identical to your coverage map. The method is a lookup, and a spelling mismatch moves an item into the wrong bucket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; find kinds your coverage map has never heard of.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"import('./gaps.mjs').then(m=&amp;gt;{const inv=require('./inv.json');
  console.log('unknown kinds:', Object.keys(inv).filter(k=&amp;gt;!(k in m.COVERAGE_C2C)));})"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;unknown kinds: []&lt;/code&gt;. Anything listed there is either a spelling mismatch or a kind you have not classified yet — both are your problem to fix now, because &lt;code&gt;gapList&lt;/code&gt; treats unknown kinds as work and will inflate your plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Compute the gap list
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;countOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`inventory["&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"] is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; give an array or a number`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gapList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;coverage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;COVERAGE_C2C&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;countOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;CARRIED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;countOf&lt;/code&gt; throws rather than returning zero. An earlier version quietly returned an empty gap list when handed counts instead of arrays — no error, no warning, and a plan that said there was nothing to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; confirm it refuses input it cannot count.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"import('./gaps.mjs').then(m=&amp;gt;{try{m.gapList({projects:{a:1}});console.log('NO THROW — bug')}
  catch(e){console.log('refused:',e.message)}})"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;refused: inventory["projects"] is object; give an array or a number&lt;/code&gt;. If it prints &lt;code&gt;NO THROW&lt;/code&gt;, the guard is missing and the tool will silently under-report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Diff the config on id
&lt;/h2&gt;

&lt;p&gt;Configuration is tenant-scoped: a priority named "P1" on each side is two objects with two ids. Anything you carry yourself — filters, boards, automation — references the id.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripMigratedSuffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="sr"&gt;migrated&lt;/span&gt;&lt;span class="se"&gt;\)\s&lt;/span&gt;&lt;span class="sr"&gt;*$/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;idRemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dupes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stripMigratedSuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;dupes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dupes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;srcNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;stripMigratedSuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ambiguous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;dupes&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;srcNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;byName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;stripMigratedSuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;orphans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;agreed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stripMigratedSuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ambiguous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// refuse, do not guess&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orphans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;agreed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;remap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orphans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agreed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ambiguous&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that are not decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The suffix.&lt;/strong&gt; Migration tooling appends &lt;code&gt;(migrated)&lt;/code&gt; when it resolves a name clash, so the destination ends up holding &lt;code&gt;Custom field A&lt;/code&gt; and &lt;code&gt;Custom field A (migrated)&lt;/code&gt;. A differ matching raw names reports those as &lt;em&gt;missing&lt;/em&gt;, which sends you creating objects that already exist. Normalise before matching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambiguity never enters **`&lt;/strong&gt;remap*&lt;em&gt;`&lt;/em&gt;&lt;em&gt;.&lt;/em&gt;* If two destination objects share a name, that name is excluded from the translation table entirely. A poisoned row in a table you intend to run is worse than an absent one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; confirm the suffix is normalised.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"import('./gaps.mjs').then(m=&amp;gt;{const r=m.idRemap('cf',
  [{id:'1',name:'Team'}],[{id:'99',name:'Team (migrated)'}]);
  console.log('orphans',r.orphans.length,'remap',r.remap.length);})"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;orphans 0 remap 1&lt;/code&gt;. If it prints &lt;code&gt;orphans 1&lt;/code&gt;, the normalisation is not running and every clash the tool resolved will look like a missing object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Prove it exits non-zero on ambiguity
&lt;/h2&gt;

&lt;p&gt;A differ that warns and returns success will be consumed by something that ignores warnings. Make it fail. Save this as &lt;code&gt;run.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;gapList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idRemap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./gaps.mjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inventory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./inv.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;srcPri&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Blocker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dstPri&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;srcLink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Blocks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Relates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dstLink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10100&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Blocks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                 &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10101&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Relates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10102&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Relates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remaps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;idRemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;priorities&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcPri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dstPri&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;idRemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;link types&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcLink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dstLink&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;gapList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;remaps&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ambiguous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;remaps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ambiguous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ambiguous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`\nFAILED: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ambiguous&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ambiguous name(s). Resolve them before trusting this table.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;report&lt;/code&gt; function is a printer — full source is in &lt;code&gt;gaps.mjs&lt;/code&gt; at the end of this article's repo layout, and any formatting you prefer will do; what matters is that every remap row and every orphan is printed rather than truncated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt; Run it and check the exit code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node run.mjs&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NOT CARRIED — you build these:
    47  automation-rules
    11  global-permissions
     6  app-data

PARTIAL — copied with documented exclusions, still check every one:
    34  custom-fields
     8  workflows

ID REMAP — identical names, different ids:
  priorities: 1 to remap, 1 already agree, 1 missing, 0 AMBIGUOUS
     "P1"  1 -&amp;gt; 10001
     "Blocker" (3)  MISSING at destination
  link types: 1 to remap, 0 already agree, 0 missing, 1 AMBIGUOUS
     "Blocks"  10000 -&amp;gt; 10100
     "Relates"  AMBIGUOUS — two destination objects share this name; REFUSED

FAILED: 1 ambiguous name(s). Resolve them before trusting this table.
exit=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to read there. Dashboards are &lt;strong&gt;absent&lt;/strong&gt; from the gap list, because they are carried — the tool is not inventing work. Custom fields and workflows appear under &lt;strong&gt;PARTIAL&lt;/strong&gt;, which is the whole reason for the three-state model. And &lt;code&gt;"Relates"&lt;/code&gt; produced &lt;strong&gt;no mapping at all&lt;/strong&gt;, with a non-zero exit.&lt;/p&gt;

&lt;p&gt;Now remove the duplicate: delete &lt;code&gt;{ id: "10102", name: "Relates" }&lt;/code&gt; from &lt;code&gt;dstLink&lt;/code&gt; and run again. You should get &lt;code&gt;0 AMBIGUOUS&lt;/code&gt;, a &lt;code&gt;Relates&lt;/code&gt; remap row, no FAILED line, and &lt;code&gt;exit=0&lt;/code&gt;. A check that cannot switch off is not testing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Assign an owner to every line
&lt;/h2&gt;

&lt;p&gt;The output is counts. Scope is counts with a name against each.&lt;/p&gt;

&lt;p&gt;Forty-seven automation rules is not a task. "Priya rebuilds the 12 rules touching the release workflow before cutover, the other 35 after" is. Every orphan is a decision too — create it at the destination, map it elsewhere, or accept the loss — and each has a different owner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked:&lt;/strong&gt; hand it to somebody who was not in the room. If they can say what they are responsible for without asking a question, it is scope. If they ask "so what do I do about app data", it is still a list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What differs per path
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Jira/JSM Data Center → Cloud (JCMA).&lt;/strong&gt; It carries more than people expect, including dashboards, cross-project boards and filters, filter subscriptions, and automation flows. The remainder is mostly small in-project detail: custom field language translations, workflow properties and triggers, project avatars, canned responses, mail handlers, issue collectors, board sub-filters — plus global permissions and general configuration. Identity dominates the plan here for a different reason: &lt;code&gt;username&lt;/code&gt; and &lt;code&gt;userKey&lt;/code&gt; do not exist in Cloud, so every user reference resolves to an &lt;code&gt;accountId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confluence Data Center → Cloud (CCMA).&lt;/strong&gt; A different tool with a different list — global settings and permissions, application links, personal drafts, custom emojis, user-created macros and audit logs are among the things it leaves. Do not reuse the Jira map here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud → Cloud (Data transfer).&lt;/strong&gt; Found at admin.atlassian.com → &lt;strong&gt;Data management&lt;/strong&gt; → &lt;strong&gt;Data transfer&lt;/strong&gt; → Create copy plan. It runs same-org or cross-org and gives you a plan with statuses to watch. Identity is smaller than on the DC path but not zero: group membership, permission changes on copied groups, blocklisted groups, users in inactive directories, and anyone who is not the same Atlassian account at the destination all need handling.&lt;/p&gt;

&lt;p&gt;You now have a gap list that distinguishes what you build from what arrives partially finished, and a remap table that refuses to guess when two destination objects share a name — proven by a run that exits 1 and a second run that exits 0 once the duplicate is gone.&lt;/p&gt;

&lt;p&gt;What this does not do is tell you whether the migration is a good idea, size the rebuild, or catch anything the copied-data page fails to mention. Re-read that page close to cutover. Everything here depends on it, and it moves — an earlier draft of this article was built on a stale reading of it and confidently listed carried objects as work.&lt;/p&gt;

&lt;p&gt;More in our &lt;a href="https://dev.to/topics/cloud-migration"&gt;Atlassian Migrations&lt;/a&gt; collection.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/tutorials/migration-gap-list-before-you-start?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>atlassian</category>
      <category>migration</category>
      <category>jira</category>
      <category>javascript</category>
    </item>
    <item>
      <title>notifyUsers=false in Jira Cloud: four ways your test bed fakes a pass</title>
      <dc:creator>Mihai Perdum</dc:creator>
      <pubDate>Sat, 03 Oct 2026 20:41:13 +0000</pubDate>
      <link>https://dev.to/mihai_leanzero/notifyusersfalse-in-jira-cloud-four-ways-your-test-bed-fakes-a-pass-go2</link>
      <guid>https://dev.to/mihai_leanzero/notifyusersfalse-in-jira-cloud-four-ways-your-test-bed-fakes-a-pass-go2</guid>
      <description>&lt;h1&gt;
  
  
  notifyUsers=false in Jira Cloud: four ways your test bed fakes a pass
&lt;/h1&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Before you trust any notification test, run POST /rest/api/3/issue/{key}/notify. A 403 means your tenant sends nothing and every suppression test you run will pass.&lt;/li&gt;
&lt;li&gt;A company-managed project created over POST /rest/api/3/project has NO notification scheme unless you pass notificationScheme explicitly. I reproduced this across three templates and two project types.&lt;/li&gt;
&lt;li&gt;user.notify.own.changes defaults to false, so a solo admin testing on their own issue gets no mail whichever route they use.&lt;/li&gt;
&lt;li&gt;All four comment routes — including PUT /issue?notifyUsers=false — raise the comment event, never jira:issue_updated. Measured with a webhook on eight issues across two runs.&lt;/li&gt;
&lt;li&gt;POST /issue/{key}/comment?notifyUsers=false returns 201 and silently drops the parameter. The comment endpoint has exactly two parameters and that is not one of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have ever had to backfill a comment onto a few thousand Jira work items, you already know the shape of the problem: the comment is the easy part, and the email storm is the part that gets you a meeting. I wrote up &lt;a href="https://leanzero.net/tutorials/suppressing-the-email-when-jira-automation-adds-a-comment-and-why-sendnotifications-does-nothing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;why &lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/suppressing-the-email-when-jira-automation-adds-a-comment-and-why-sendnotifications-does-nothing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;&lt;code&gt;sendNotifications: false&lt;/code&gt;&lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/suppressing-the-email-when-jira-automation-adds-a-comment-and-why-sendnotifications-does-nothing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt; does nothing when you paste it into an automation rule's exported JSON&lt;/a&gt; a few weeks ago. The short version is that it is not a parameter of that action, so nothing reads it.&lt;/p&gt;

&lt;p&gt;That piece ended on a workaround I had not run. Atlassian publishes it themselves, on &lt;strong&gt;JRACLOUD-97682&lt;/strong&gt;, and it is the thing everybody reaches for next: skip the comment endpoint, ride the comment in on the &lt;em&gt;edit issue&lt;/em&gt; endpoint, which does accept &lt;code&gt;notifyUsers=false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So I built a test bed on a live Jira Cloud tenant and ran it. This tutorial is that bed, end to end — because the useful finding turned out not to be the workaround at all. It was that the bed lied to me four separate times, and every one of those lies looks exactly like success.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Atlassian actually documents
&lt;/h2&gt;

&lt;p&gt;Start from the primary source, not from a forum answer. I pulled the Jira Cloud platform OpenAPI document (&lt;code&gt;developer.atlassian.com/cloud/jira/platform/swagger-v3.json&lt;/code&gt;, 3,618,606 bytes, fetched 24 August 2026) and read the three relevant operations out of it directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The add-comment endpoint takes exactly two parameters:&lt;/strong&gt; &lt;code&gt;issueIdOrKey&lt;/code&gt; and &lt;code&gt;expand&lt;/code&gt;. That is the whole list. There is no &lt;code&gt;notifyUsers&lt;/code&gt;, no &lt;code&gt;sendNotifications&lt;/code&gt;, nothing. This is the machine-readable confirmation of the earlier article's conclusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The edit-issue endpoint's **`&lt;/strong&gt;notifyUsers*&lt;em&gt;`&lt;/em&gt;* reads, verbatim:**&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Whether a notification email about the issue update is sent to all watchers. To disable the notification, administer Jira or administer project permissions are required. If the user doesn't have the necessary permission the request is ignored.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that carefully, because two clauses in it matter later. It promises to suppress mail "about the issue update" — it says nothing about the comment event. And a missing permission gets you &lt;strong&gt;ignored&lt;/strong&gt;, never refused, so the call succeeds and the mail goes out anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JRACLOUD-97682's own **`&lt;/strong&gt;h3. Workaround**` gives the literal call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nt"&gt;--request&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'https://sitename.atlassian.net/rest/api/3/issue/ABC-1?notifyUsers=false'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Basic ...'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "update": {
      "comment": [
        { "add": { "body": { "type": "doc", "version": 1, "content": [
          { "type": "paragraph", "content": [
            { "type": "text", "text": "Comment added via the Edit issue API." }
          ]}
        ]}}}
      ]
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I re-read the three tracker items over the JAC REST API on 24 August 2026, because vote counts and statuses rot and the numbers in my own notes were already stale:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ticket&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Votes&lt;/th&gt;
&lt;th&gt;Watchers&lt;/th&gt;
&lt;th&gt;Created&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AUTO-602 — suppress in-product notifications from Automation&lt;/td&gt;
&lt;td&gt;Gathering Interest&lt;/td&gt;
&lt;td&gt;1,073&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;2020-04-29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JRACLOUD-97682 — add notifyUsers=false to the comments API&lt;/td&gt;
&lt;td&gt;Gathering Interest&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2026-03-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JRACLOUD-78140 — implement notifyUsers on a number of API calls&lt;/td&gt;
&lt;td&gt;Closed / Won't Do&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2022-01-06&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two notes on citing these. AUTO-602 opens with the sentence you want — &lt;em&gt;"Currently, we can only suppress Email notifications for the Edit Issue action performed by automation or Jira"&lt;/em&gt; — but its title and its request are about in-product and in-app notifications, so cite it for the limitation it states and not for the thing it asks for. And do not reach for JRACLOUD-78140 at all: it is Closed / Won't Do, and its description asks for &lt;code&gt;notifyUsers&lt;/code&gt; on exactly four operations — &lt;em&gt;"create issue, bulk create issue, assign issue, transition issue"&lt;/em&gt;. Adding a comment is not one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the bed
&lt;/h2&gt;

&lt;p&gt;Everything below ran against a sanctioned test tenant over Basic auth with an API token, as a site admin. None of it belongs on a production site until you have read the whole article.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a throwaway company-managed project
POST /rest/api/3/project with a projectTemplateKey, a leadAccountId, and — this is the part that matters — an explicit notificationScheme.&lt;/li&gt;
&lt;li&gt;Confirm the project actually has a notification scheme
GET /rest/api/3/project/{key}/notificationscheme. If this 404s, stop; nothing you measure afterwards means anything.&lt;/li&gt;
&lt;li&gt;Put a recipient on the Issue Commented event whose mail you can actually read, using a recipient type Jira Cloud still supports.&lt;/li&gt;
&lt;li&gt;Register a webhook on comment_created and jira:issue_updated, filtered to the project, so you can see which event each route raises.&lt;/li&gt;
&lt;li&gt;Prove the tenant can send mail at all
POST /rest/api/3/issue/{key}/notify. This is the control, and it is the step everyone skips.&lt;/li&gt;
&lt;li&gt;Run each route on its own fresh work item, with a plain summary edit as the control route, then read the webhook capture and the mailbox.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps two, three and five each turned up a way to get a confident, wrong answer, and the tenant's own plan turned up a fourth. Here they are in the order they bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  False negative 1 — the project has no notification scheme
&lt;/h2&gt;

&lt;p&gt;I created the project the obvious way, then asked it for its notification scheme:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /rest/api/3/project/NTFY/notificationscheme
404 {"errorMessages":["No notification scheme associated with this project."]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A company-managed project, freshly created, with no notification scheme at all. Not an empty one — none. Nothing in that project will ever raise scheme-driven mail, so every suppression test I ran in it would have come back clean.&lt;/p&gt;

&lt;p&gt;I did not want to guess at the cause, so I isolated it. Three different templates, across two project types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template&lt;/th&gt;
&lt;th&gt;Project type&lt;/th&gt;
&lt;th&gt;Scheme after create&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gh-simplified-basic&lt;/td&gt;
&lt;td&gt;software&lt;/td&gt;
&lt;td&gt;none — 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gh-simplified-scrum-classic&lt;/td&gt;
&lt;td&gt;software&lt;/td&gt;
&lt;td&gt;none — 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gh-simplified-kanban-classic&lt;/td&gt;
&lt;td&gt;software&lt;/td&gt;
&lt;td&gt;none — 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jira-core-simplified-process-control&lt;/td&gt;
&lt;td&gt;business&lt;/td&gt;
&lt;td&gt;none — 404&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then the same call with one field added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NTX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"projectTypeKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"software"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"projectTemplateKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.pyxis.greenhopper.jira:gh-simplified-basic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"leadAccountId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"notificationScheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That project came up with &lt;code&gt;10000 Default Notification Scheme&lt;/code&gt; attached. So it is not the template and not the project type — &lt;code&gt;**POST /rest/api/3/project**&lt;/code&gt;** simply does not attach a default notification scheme unless you name one.** If you already have projects in this state, &lt;code&gt;PUT /rest/api/3/project/{key}&lt;/code&gt; with &lt;code&gt;{"notificationScheme": 10000}&lt;/code&gt; retro-fits it; that returned 200 and the project reported the scheme immediately.&lt;/p&gt;

&lt;p&gt;This one has a life beyond test beds. If you provision projects over the API — and plenty of teams do, this is the same class of gap as &lt;a href="https://leanzero.net/tutorials/jira-automation-rules-migration-actor-scope?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;automation rules not travelling with the projects they belong to&lt;/a&gt; — then the people in those projects are getting no notifications, and nobody files a ticket about email they never expected.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A 404 from &lt;code&gt;GET /rest/api/3/project/{key}/notificationscheme&lt;/code&gt; is not a permissions problem and not an API version problem. It is the literal absence of a scheme. Check it on every project you provision over REST.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  False negative 2 — you don't get mail for your own changes
&lt;/h2&gt;

&lt;p&gt;The second trap is the cheapest to fall into, because the natural way to test this is to comment on an issue you are watching and see whether your own inbox lights up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /rest/api/3/mypreferences?key=user.notify.own.changes
false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the default, and it is per-user. With it off, Jira will not mail you about actions you performed yourself — so a solo admin testing suppression on their own work item gets silence from the suppressed route &lt;em&gt;and&lt;/em&gt; silence from the unsuppressed one, and concludes the suppression works.&lt;/p&gt;

&lt;p&gt;You can flip it with a &lt;code&gt;PUT&lt;/code&gt; to the same endpoint, but I would rather not test a notification path using the one account whose notifications are specially suppressed. I put a separate recipient on the scheme instead, which is what step three of the bed is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  False negative 3 — the whole tenant has outgoing mail switched off
&lt;/h2&gt;

&lt;p&gt;This is the one that would have taken the article out at the knees, and I only caught it because I ran a positive control before believing a negative.&lt;/p&gt;

&lt;p&gt;My routes had all returned success. No mail had arrived. That is exactly the result "the workaround works" would produce. So before writing a word of it, I asked the tenant to send me an email it had no reason to refuse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /rest/api/3/issue/NTFY-9/notify
{"subject":"positive control","textBody":"probe","to":{"users":[{"accountId":"…"}]}}

403 {"errorMessages":["Outgoing emails disabled."]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reproduced on three different work items. Atlassian's own OpenAPI document gives that status code exactly one meaning for this operation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;403&lt;/strong&gt; — Returned if: outgoing emails are disabled. no SMTP server is configured.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those are two causes on paper and one in practice, because Atlassian's outgoing-mail page states that &lt;em&gt;"In Jira Cloud, Outgoing Mail can only be enabled or disabled — there is no SMTP server to configure."&lt;/em&gt; On Cloud there is no second explanation available: a 403 here means the switch is off.&lt;/p&gt;

&lt;p&gt;So the tenant sends nothing, at all, by any route. Every "no email arrived" result I had collected was worth precisely zero — and had I skipped this call, I would have published a confident measurement of a mechanism I had never once exercised. This is the same discipline that catches a &lt;a href="https://leanzero.net/tutorials/field-options?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;Jira Cloud field quietly defaulting to a value nobody set&lt;/a&gt;: the absence of a symptom is not evidence until you have proved the instrument can produce one.&lt;/p&gt;

&lt;p&gt;And the wrong control would have fooled me a second time. When I created the recipient account, an Atlassian invite email landed in the capture mailbox within a minute — proof, it looked like, that mail from this tenant reaches me. It is not. The same outgoing-mail page lists the exception: &lt;em&gt;"Actions via **admin.atlassian.com&lt;/em&gt; (user invites, access requests, join notifications) — sent from a separate address." That mail proves the mailbox works. It says nothing about whether Jira will send a notification, because it did not come from Jira's notification path at all. A positive control has to exercise the &lt;strong&gt;same&lt;/strong&gt; mechanism you are about to trust, not a neighbouring one that happens to be easier to trigger.&lt;/p&gt;

&lt;p&gt;The setting itself is a site-level toggle. Atlassian's page for it carries a &lt;strong&gt;Cloud Only&lt;/strong&gt; platform notice, puts it at &lt;strong&gt;⚙️ Settings → System → Outgoing mail&lt;/strong&gt;, and requires the &lt;em&gt;Administer Jira&lt;/em&gt; global permission. Two sentences from it are worth memorising:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disabling Outgoing Mail stops all notifications from &lt;code&gt;jira@&amp;lt;domain&amp;gt;.atlassian.net&lt;/code&gt;. Notifications are not queued.&lt;/p&gt;

&lt;p&gt;The Automation &lt;strong&gt;Send email&lt;/strong&gt; action — still sends even with Outgoing Mail disabled (see AUTO-112).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That second one deserves a moment. If your plan for a risky backfill is "I'll flick outgoing mail off for an hour", it does not cover you: an automation rule with a Send email action will keep mailing people throughout. And because nothing is queued, the notifications you &lt;em&gt;did&lt;/em&gt; want during that hour are gone rather than delayed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Searching for how to disable outgoing mail over REST will hand you an Atlassian KB describing &lt;code&gt;PUT /rest/jira-mail-plugin/1.0/outgoingMail/config&lt;/code&gt;. That article carries a &lt;strong&gt;Data Center Only&lt;/strong&gt; platform notice. Atlassian's docs run as two parallel trees and the Data Center one frequently outranks the Cloud one — check the platform notice at the top of every page before you copy a path out of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I did not enable outgoing mail on this tenant to finish the measurement. It is a shared test site and I could not enumerate what else would have started sending the moment I flipped it — and an unknown blast radius is a reason not to proceed, not a reason to proceed carefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  False negative 4 — the Free plan cap
&lt;/h2&gt;

&lt;p&gt;The fourth one I did not measure directly, and I am labelling it as documented rather than tested. This tenant reports &lt;code&gt;FREE&lt;/code&gt; for all three products, which I found the hard way when Jira refused a project role edit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /rest/api/3/project/NTFY/role/10002
400 {"errorMessages":["You can't update role actors for this project as it's on the Jira Software Free plan."]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atlassian's notification documentation states the cap plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Jira can send a maximum of 100 emails per day on the Free plan. After 100 emails, notifications are paused until the following day.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And, for anyone who upgrades to fix it: &lt;em&gt;"it may take up to 30 days for this limit to be lifted."&lt;/em&gt; So a test run on a busy Free tenant late in the day sends nothing, silently, for reasons that have nothing to do with the thing you are testing — and a test run the following morning behaves differently. If you are testing notification behaviour on a Free sandbox, check the plan before you check anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the routes actually do
&lt;/h2&gt;

&lt;p&gt;With the bed honest about what it could and could not tell me, here is what I measured. Four comment routes, plus two plain-summary-edit routes as controls, each on its own fresh work item, with a webhook capturing &lt;code&gt;comment_created&lt;/code&gt; and &lt;code&gt;jira:issue_updated&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;Comment created&lt;/th&gt;
&lt;th&gt;Webhook event raised&lt;/th&gt;
&lt;th&gt;Changelog entry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A · POST /issue/{k}/comment&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;comment_created&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B · PUT /issue/{k}?notifyUsers=false&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;comment_created&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C · PUT /issue/{k} (no flag)&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;comment_created&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D · POST /issue/{k}/comment?notifyUsers=false&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;comment_created&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E · PUT /issue/{k}?notifyUsers=false (summary edit)&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;jira:issue_updated&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F · PUT /issue/{k} (summary edit, no flag)&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;jira:issue_updated&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things fall out of that table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Riding a comment in on the edit endpoint does not turn it into an issue update.&lt;/strong&gt; Routes B and C never raised &lt;code&gt;jira:issue_updated&lt;/code&gt; — only &lt;code&gt;comment_created&lt;/code&gt;. Routes E and F, which edit an actual field, raise &lt;code&gt;jira:issue_updated&lt;/code&gt; and nothing else. I ran the comment routes twice, on eight separate work items, and got the same split both times; E and F are the control that proves the observer can tell the two apart.&lt;/p&gt;

&lt;p&gt;That matters because notification schemes key on &lt;strong&gt;events&lt;/strong&gt;, not on which endpoint you called. &lt;code&gt;GET /rest/api/3/events&lt;/code&gt; returns the catalogue your schemes are built from; on this tenant it is id 1 Issue Created, 2 Issue Updated, 3 Issue Assigned, 4 Issue Resolved, 5 Issue Closed, 6 Issue Commented, 13 Generic Event and 14 Issue Comment Edited.&lt;/p&gt;

&lt;p&gt;So the row governing all four comment routes is &lt;strong&gt;Issue Commented (6)&lt;/strong&gt;, not Issue Updated (2) — while &lt;code&gt;notifyUsers&lt;/code&gt; is documented against "a notification email about the issue update". The workaround is asking a flag scoped to one event to suppress a different one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;**POST /comment?notifyUsers=false**&lt;/code&gt;** returns 201 and throws the parameter away.** No error, no warning, comment created. It is the same failure mode as &lt;code&gt;sendNotifications: false&lt;/code&gt; in an automation export, one layer down: the parameter is accepted by the transport and never read by the operation. If you have this in a script and no email storm has happened yet, it is not because the flag is working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;None of the comment routes write a changelog entry.&lt;/strong&gt; Convenient if you are backfilling and did not want to pollute issue history; inconvenient if you were relying on the history tab to audit what your script did.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two limits worth knowing before you write the loop
&lt;/h3&gt;

&lt;p&gt;You cannot batch. &lt;code&gt;update.comment&lt;/code&gt; is a JSON array, which reads like an invitation to add several comments per call. Jira refuses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PUT /rest/api/3/issue/{k}?notifyUsers=false
{"update":{"comment":[{"add":{…}},{"add":{…}}]}}

400 {"errors":{"comment":"Too many operations (2) provided for field 'comment'.
     We support at most 1 operation for this field."}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One comment per call, so a four-hundred-comment backfill is four hundred calls whichever route you pick. On this tenant, over twenty sequential calls each, the edit route is about fifteen percent slower per call and about twenty percent slower over the run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;th&gt;20 calls&lt;/th&gt;
&lt;th&gt;Extrapolated to 400&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;POST /issue/{k}/comment&lt;/td&gt;
&lt;td&gt;352 ms&lt;/td&gt;
&lt;td&gt;242–468 ms&lt;/td&gt;
&lt;td&gt;7.1 s&lt;/td&gt;
&lt;td&gt;≈ 2.4 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUT /issue/{k}?notifyUsers=false&lt;/td&gt;
&lt;td&gt;403 ms&lt;/td&gt;
&lt;td&gt;344–668 ms&lt;/td&gt;
&lt;td&gt;8.6 s&lt;/td&gt;
&lt;td&gt;≈ 2.9 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither run hit a rate limit at that pace, but that is a single-threaded sequential loop on a quiet tenant and it is not a licence to fan out.&lt;/p&gt;

&lt;p&gt;And the comment you get is indistinguishable. Reading both back over the API, route A and route B produce the same field set — &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, &lt;code&gt;created&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;jsdPublic&lt;/code&gt;, &lt;code&gt;self&lt;/code&gt;, &lt;code&gt;updateAuthor&lt;/code&gt;, &lt;code&gt;updated&lt;/code&gt; — with the same author, the same &lt;code&gt;jsdPublic: true&lt;/code&gt; and the same &lt;code&gt;visibility: null&lt;/code&gt;. Nothing but the id and the timestamp separates them. So if the flag does work for you, nothing downstream will know how the comment arrived. That cuts both ways, which is a familiar shape for anyone who has &lt;a href="https://leanzero.net/tutorials/jsm-attachment-asset?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;pushed data into a Jira surface the public docs do not describe&lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/jsm-attachment-asset?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;.&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The lever that is certain
&lt;/h2&gt;

&lt;p&gt;If you need a guarantee rather than a workaround, do not reach for a flag whose behaviour you cannot verify on your own tenant. Empty the &lt;strong&gt;Issue Commented&lt;/strong&gt; row of the project's notification scheme for the duration of the backfill, then restore it. It is unglamorous, fully reversible, and it does not depend on undocumented behaviour.&lt;/p&gt;

&lt;p&gt;One trap while you are in there. The old advice — add a single email address to the notification scheme so you can watch what fires — does not work on Cloud any more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /rest/api/3/notificationscheme
{"notificationSchemeEvents":[{"event":{"id":"6"},
  "notifications":[{"notificationType":"EmailAddress","parameter":"me@example.com"}]}]}

400 {"errorMessages":["Recipient type EmailAddress is not supported."]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I enumerated the whole set against the live API rather than trusting a doc page. These are accepted: &lt;code&gt;CurrentAssignee&lt;/code&gt;, &lt;code&gt;Reporter&lt;/code&gt;, &lt;code&gt;CurrentUser&lt;/code&gt;, &lt;code&gt;ProjectLead&lt;/code&gt;, &lt;code&gt;ComponentLead&lt;/code&gt;, &lt;code&gt;User&lt;/code&gt;, &lt;code&gt;ProjectRole&lt;/code&gt;, &lt;code&gt;AllWatchers&lt;/code&gt;. These are not: &lt;code&gt;EmailAddress&lt;/code&gt; (&lt;em&gt;"not supported"&lt;/em&gt;) and &lt;code&gt;IssueRole&lt;/code&gt; (&lt;em&gt;"not found"&lt;/em&gt;).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Group&lt;/code&gt; is accepted, and it has a trap in it that caught me while I was fact-checking this article. It wants the group &lt;strong&gt;name&lt;/strong&gt;, and it rejects the group &lt;strong&gt;id&lt;/strong&gt; — which is the opposite of the direction Jira Cloud has been moving in everywhere else. I tested three real groups both ways: the name returned 201 every time, and the corresponding &lt;code&gt;groupId&lt;/code&gt; returned 400 &lt;em&gt;"…is not a valid parameter"&lt;/em&gt; every time. I had written the reverse into this article from inference alone, and only caught it because I went back and ran it.&lt;/p&gt;

&lt;p&gt;So to watch notifications land, you need a recipient that is a real account. Create one whose mailbox you can read, put it on the event as a &lt;code&gt;User&lt;/code&gt;, and confirm it can browse the project — &lt;code&gt;GET /rest/api/3/user/permission/search?accountId=…&amp;amp;permissions=BROWSE_PROJECTS&amp;amp;projectKey=…&lt;/code&gt; will tell you, and a recipient who cannot see the work item will not be mailed about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove the negative, don't observe it
&lt;/h2&gt;

&lt;p&gt;The thread running through all four of these is one rule: &lt;strong&gt;a negative that authorises action has to be proved, not observed.&lt;/strong&gt; A count of zero, an empty list, a quiet inbox — none of those license a conclusion until you have shown that the instrument can produce a non-zero on the same object.&lt;/p&gt;

&lt;p&gt;We ended up encoding that rule into our own agent, which is the only reason I thought to run the &lt;code&gt;notify&lt;/code&gt; call at all. &lt;a href="https://leanzero.net/tutorials/inside-goose-swarm-plan-execute-judge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;goose&lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/inside-goose-swarm-plan-execute-judge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;, the local-model swarm fork we build on, had a live bug of exactly this shape: when every model alias was withdrawn because a node dropped off the LAN, the scheduler saw an empty served-model list, could not distinguish "nothing is servable" from "the probe is broken", and dispatched the whole run into a wall of 400s. The fix is a guard called &lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/inside-goose-swarm-plan-execute-judge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;&lt;code&gt;all_resident_unservable&lt;/code&gt;&lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/inside-goose-swarm-plan-execute-judge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt; in &lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/inside-goose-swarm-plan-execute-judge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;&lt;code&gt;crates/goose-cli/src/commands/swarm.rs&lt;/code&gt;&lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/inside-goose-swarm-plan-execute-judge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;, and its whole design is this distinction: it refuses to start only when the endpoint returned a &lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/inside-goose-swarm-plan-execute-judge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;&lt;strong&gt;non-empty&lt;/strong&gt;&lt;/a&gt;&lt;a href="https://leanzero.net/tutorials/inside-goose-swarm-plan-execute-judge?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt; catalogue that contains none of the resident models — a proven zero. An empty or unreachable probe never refuses, because that is an observed zero and it means nothing. Its unit tests assert precisely that asymmetry.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /rest/api/3/issue/{key}/notify&lt;/code&gt; is the Jira equivalent, and it costs one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-u&lt;/span&gt; you@example.com:&lt;span class="nv"&gt;$TOKEN&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'https://your-site.atlassian.net/rest/api/3/issue/ABC-1/notify'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"subject":"control","textBody":"control","to":{"reporter":true}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;204&lt;/code&gt; means the tenant will send. &lt;code&gt;403&lt;/code&gt; means it will not, and everything you were about to measure is void. Run it first, and run it again at the end.&lt;/p&gt;

&lt;p&gt;One wrinkle to expect, because it will otherwise read as a failure: this endpoint also returns &lt;strong&gt;400&lt;/strong&gt; when &lt;em&gt;"the recipient is the same as the calling user"&lt;/em&gt;. If you happen to be the reporter of the work item you picked, &lt;code&gt;{"to":{"reporter":true}}&lt;/code&gt; gets you a 400 that has nothing to do with outgoing mail. Point it at a colleague's &lt;code&gt;accountId&lt;/code&gt;, or pick a work item somebody else raised.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I could not settle
&lt;/h2&gt;

&lt;p&gt;I set out to answer one question — does &lt;code&gt;notifyUsers=false&lt;/code&gt; on the edit-issue endpoint actually suppress the Issue Commented email — and I cannot answer it. The tenant I have admin on cannot send mail, and I was not willing to switch site-wide outgoing mail on to find out.&lt;/p&gt;

&lt;p&gt;So, keeping the three registers apart:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documented.&lt;/strong&gt; &lt;code&gt;notifyUsers&lt;/code&gt; suppresses "a notification email about the issue update", requires Administer Jira or Administer Projects, and is ignored rather than refused without them. The add-comment endpoint has no equivalent parameter. Atlassian publishes the edit-issue route as the workaround on JRACLOUD-97682.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measured, here, on 24 August 2026.&lt;/strong&gt; Everything in the tables above: the routes, the events, the latencies, the one-operation limit, the identical comment representation, the missing notification schemes, the unsupported recipient types, the &lt;code&gt;user.notify.own.changes&lt;/code&gt; default, and the 403.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still a guess.&lt;/strong&gt; Whether the flag reaches the Issue Commented event. The webhook evidence says that event is what gets raised, and the parameter is documented against a different one — which makes me sceptical rather than certain. If you have a tenant with outgoing mail on, the bed above will settle it in about ten minutes, and I would genuinely like to know.&lt;/p&gt;

&lt;p&gt;Whatever you find, do not find it the way I nearly did. Run the control first.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://leanzero.net/tutorials/jira-notification-test-bed-false-negatives?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net&lt;/a&gt;. More Atlassian, Forge and local-AI write-ups at &lt;a href="https://leanzero.net/blog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/blog&lt;/a&gt;, and if you're planning a migration or a Forge app, that's what we do: &lt;a href="https://leanzero.net/services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;leanzero.net/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>jira</category>
      <category>atlassian</category>
      <category>testing</category>
      <category>api</category>
    </item>
  </channel>
</rss>
