jira:workflowCondition vs jira:workflowValidator: one runs your code
Mihai Perdum
Author
14 min readAugust 14, 2026
Key takeaways
The jira:workflowCondition module has no function property. Only expression is supported, and it is required. A function: key on a condition is silently ignored by the Forge CLI, by forge deploy's manifest validation, and by Jira.
Measured on the probe app: a condition with expression:"false" hides the transition from GET /issue/{key}/transitions AND rejects a forced POST with HTTP 400. Forge conditions are enforced on the REST path — an earlier finding of ours said the opposite and was wrong.
Zero invocations of the condition's declared function across the whole run. The validator's function logged once, with payload keys issue, configuration, context, user, transition, contextToken.
An AI-backed condition is not hard on Forge, it is impossible: a Jira expression is evaluated inside Jira with no HTTP, capped at 10 expensive operations and 50,000 steps.
Two things do work: a jira:workflowValidator with a function (25-second synchronous budget), or a condition expression that reads a precomputed verdict out of an issue entity property. Both measured here.
Two Forge modules look like they do the same job. jira:workflowCondition hides a transition the user should not take. jira:workflowValidator lets them press the button and then refuses with a message. Pick whichever fits the UX, wire both to the same handler, ship.
That is exactly what we did in CogniRunner, our Forge app that puts an AI check on a Jira workflow transition. The manifest declared a validator and a condition, both pointing at the same exported validate function. Both had a full configuration UI, so an admin could pick a field, write a prompt, save it, and see it listed in the workflow editor.
One of those two modules never called our code. Not once. And nothing anywhere said so — not the CLI, not the deploy, not the runtime, not a log line.
What we shipped
Here is the shape, reduced to the part that matters:
The two blocks are near-identical. The one difference nobody argued about at the time is expression: "true" on the condition — the schema wanted an expression, we had our logic in the function, so we gave it a constant to get past validation and moved on.
That constant is the whole story.
The manifest reference says it plainly
I went back to the primary source. Both pages, read 2026-08-14:
The jira:workflowValidator reference says: "The validator requires either the function or the expression in your Forge app. Only one of the two properties must be present."
The jira:workflowCondition reference has no function property at all. expression is required, and the page states: "A workflow condition only evaluates to true if the provided Jira expression evaluates to true."
jira:workflowValidator
jira:workflowCondition
function
supported
does not exist
expression
supported
required
pick one
exactly one of the two
expression, always
evaluated by
your Forge function, in a Node runtime
Jira, in the Jira expressions sandbox
create / edit / view UI
yes
yes
6 rows × 3 columnsHeader row enabled
Read that table and our manifest together and the bug is obvious. Read our manifest alone and it is invisible, because both modules have a create resource, both have a view resource, both appear in the workflow editor, and both save their configuration. The condition's admin UI works perfectly. It stores a prompt that nothing will ever read.
The reference page even tells you what that UI is for, and I had not registered it: the condition's create resource is described as the one that "allows you to configure the expression-based workflow condition on creation". Expression-based. It says so three times in three consecutive rows. A configuration UI on a condition exists so an admin can build the expression — not so your app can collect parameters for a function that will be called later, because there is no later.
The CLI will not tell you
This is the part I want to be precise about, because "you should have read the docs" is a cheap answer when four separate gates had the chance to say something and none of them did.
I built a throwaway app — a jira:workflowCondition carrying both an expression and a function, exactly like ours — and ran it through every check Forge offers.
bash
1$ forge lint
2The linter checks the app code for known errors.
3No issues found.
That is Forge CLI 12.21.0. The CLI told me it was out of date, so I checked the current one too, in case this had since been caught:
Validating manifest is a real step running on Atlassian's side, and it accepted a property the module schema does not define. So did Jira: the rule installed and showed up in the workflow editor's rule list. Querying Jira for what it thinks it received:
forge:expression-condition. Jira's own name for the rule contains the word expression. The platform is telling you what it is going to evaluate, in the identifier, and it is the only place anything tells you.
Warning
An unknown key on a Forge module is not an error at any layer. It is not a lint warning, not a deploy failure, not a runtime exception. If you have ever assumed forge lint passing means your manifest means what you think it means, this is the counter-example. We hit the same class of silence around jira:fullPage and global:fullPage, where the deprecated module kept deploying happily long after the behaviour underneath it had changed.
The probe
The reasoning above is sound but it is still reasoning. Here is the app that settles it. Two modules, one deliberately rigged to be visible if it ever runs.
manifest.yml:
yaml
1modules:2jira:workflowCondition:3-key: probe-condition
4name: Probe Condition (always false)
5description: expression is the constant false. A function is also declared.
6function: condGate
7expression:"false"8projectTypes:9- company-managed
10jira:workflowValidator:11-key: probe-validator
12name: Probe Validator (always false)
13description: function-based validator that always blocks.
14function: valGate
15projectTypes:16- company-managed
17function:18-key: condGate
19handler: index.condGate
20-key: valGate
21handler: index.valGate
22app:23runtime:24name: nodejs22.x
src/index.js:
javascript
1exportconstcondGate=async(payload)=>{2console.log('PROBE_CONDITION_FUNCTION_INVOKED',JSON.stringify(Object.keys(payload ||{})));3return{result:true};4};56exportconstvalGate=async(payload)=>{7console.log('PROBE_VALIDATOR_FUNCTION_INVOKED',JSON.stringify(Object.keys(payload ||{})));8return{result:false,errorMessage:'PROBE: blocked by the validator function'};9};
The condition's expression is the constant false, so if Jira evaluates it the transition must disappear. Its function returns true, so if Jira calls the function instead the transition must stay. The two answers are opposites, which is the point — whichever wins is unambiguous. The validator is the positive control: a plain function-backed rule that always blocks with a message I can recognise.
I attached both to two self-loop transitions on the same status of the same company-managed workflow, on our wolfaenpak test site, over POST /rest/api/3/workflows/update. Same issue, same status, same transition shape — the only variable is the rule.
Result 1: what Jira lists
text
1GET /rest/api/3/issue/COGTEST-2614/transitions
23 LZPROBE-Cond-AlwaysFalse: NOT LISTED
4 LZPROBE-Val-AlwaysFalse: LISTED (id 9991)
5 (total transitions listed: 570)
The expression won. The condition's function returned true and the transition is gone anyway — while 570 transitions came back in that same call, including the validator's, so nothing was broken about the listing itself.
Result 2: what happens if you force it
Not being listed is a UI concern. The real question is whether the gate holds when a script posts the transition ID directly:
text
1POST /rest/api/3/issue/COGTEST-2614/transitions {"transition":{"id":"9990"}}
2 -> HTTP 400
3 {"errorMessages":["Can't move (COGTEST-2614). You might not have permission,
4 or the work item is missing required information. ..."]}
56status after: Backlog
Rejected. The issue did not move.
For contrast, the validator on the same status:
text
1POST /rest/api/3/issue/COGTEST-2614/transitions {"transition":{"id":"9991"}}
2 -> HTTP 400
3 {"errorMessages":["PROBE: blocked by the validator function"]}
Also rejected — but with my message, generated by my code.
Result 3: the logs
One line for the entire run:
text
1$ forge logs -e development
2INFO 2026-08-14T05:08:39.796Z PROBE_VALIDATOR_FUNCTION_INVOKED
3 ["issue","configuration","context","user","transition","contextToken"]
PROBE_CONDITION_FUNCTION_INVOKED never appears. Zero times, across the listing calls and the forced transition. The function you declared on a condition is not called late, or called and ignored — it is not called.
Incidentally that log line is also the cheapest way to learn the validator payload shape: issue, configuration, context, user, transition, contextToken on a transition with no transition screen. CogniRunner's handler additionally destructures modifiedFields, which is what you read on issue create, where issue.key is still null.
What we had wrong in our own notes
Our test harness carries a numbered findings file, and F3 in it says:
F3 — Forge conditions are not enforced on the REST transition path. Conditions gate transition visibility in the UI, but REST-driven transitions bypass them.
That is wrong, and the probe above is what proves it. Conditions are enforced on the REST path, in both halves: the transition is withheld from the listing and a forced POST is rejected with a 400.
The reason we believed otherwise is worth more than the correction. The original test drove CogniRunner's own condition, and CogniRunner's condition declares expression: "true". A tautology passes on every surface. We watched a transition stay available over REST, saw zero invocations of our function, and concluded REST was skipping conditions — when the actual explanation was that our condition was configured to allow everything, always, everywhere. The observation was real. The inference was unsupported, because the experiment could not distinguish the two hypotheses. It took a condition that says false to tell them apart.
Three registers, kept separate:
Documented.jira:workflowCondition takes no function; expression is required.
Measured here, 2026-08-14. The expression decides, on both the REST listing and REST execution. The declared function is never invoked. forge lint on 12.21.0 and 13.3.0, and forge deploy's manifest validation, all accept the illegal key.
Not measured. I tested the REST transitions API, not the new issue-view transition menu. Atlassian's own page says a failing condition means "the user won't see the transition button on the View issue page", and I have no reason to doubt it, but I did not put a browser in front of it, so I am not claiming it.
Why an AI condition is not merely hard
Once you accept that a condition is an expression, the design consequence follows and it is absolute. Jira expressions are a domain-specific language evaluated on the Jira side, in a sandbox whose entire surface is Jira's own entities. There is no facility in it for an outbound request — no way to reach a model, your app's storage, or any external service. The published budget is 10 expensive operations, 50,000 steps and 10,000 characters of source, which tells you what kind of thing it was built for.
So "hide this transition unless a model approves the description" is not a thing you implement badly on Forge. It is not a thing you implement. Any Forge app promising an AI-driven condition is either doing something else under the name, or it is doing what we were doing: storing a prompt in a config UI attached to a rule that gates nothing.
That is why the failure is so quiet. There is no error to raise. The expression evaluated, returned true, and Jira allowed the transition — which is precisely what a condition is supposed to do when it passes.
What actually works, option 1: a validator
The straightforward answer, and the one we now recommend inside the product, is to stop reaching for a condition. A validator's function runs your Node code inside the transition path — including the REST path, as measured above — and can do everything a Forge function can do — including calling a model, which is the full walkthrough we published separately.
javascript
1exportconstvalidate=async({ issue, configuration, modifiedFields, context })=>{2// issue.key is null on CREATE — read modifiedFields there instead3const value = issue?.key
4?awaitreadField(issue.key, configuration.fieldId)5: modifiedFields?.[configuration.fieldId];67const verdict =awaitaskModel(configuration.prompt, value);89return verdict.ok10?{result:true}11:{result:false,errorMessage: verdict.reason};12};
Two constraints decide whether this is viable for you, and neither is obvious until you are in production:
The 25-second wall. A workflow validator is a synchronous Forge invocation, and Atlassian's invocation limits give those 25 seconds of runtime. That is your entire budget: read the field, call the model, parse the answer, return. Async event consumers get far more room — up to 900 seconds via timeoutSeconds — but a queue cannot block a transition that has already happened. If your prompt needs a slow model or several rounds of tool-calling, the transition path is the wrong place for it and you should be moving the expensive part out.
Fail open or fail closed. The model times out, the provider rate-limits you, the key expires. Returning { result: false } on an infrastructure error blocks work for reasons nobody can act on; returning { result: true } means your governance control quietly evaporates during an outage. There is no universally correct answer, only a decision you should make deliberately and write down. The split we settled on splits by error class rather than by rule type: a transient failure — 429, 408, 5xx, timeout, network — fails open, because a rate limit during a bulk transition should not block legitimate work; a configuration failure — 401, 403, 404, 400 — fails closed, because a wrong key is persistent and every AI-guarded transition should stop until an admin fixes it. A genuine "this content is invalid" verdict blocks, which is the whole point.
That 25-second wall shapes the code more than it looks. Left unbounded, a slow provider pushes validate() past the limit, the platform kills the invocation, and Jira surfaces a raw "error in validator" — an ungraceful stop that behaves like fail-closed without ever having decided to. So the AI call carries its own deadline below the platform's, at 21 seconds, leaving roughly four for writing logs and returning cleanly. If you take one implementation detail from this article, take that one: bound your own call inside a synchronous Forge module, or the platform will bound it for you and you will not like the shape of the result.
The honest trade is that a validator cannot hide anything. The button is there, the user presses it, and only then do they get told no. If your requirement was genuinely hide it, a validator does not meet it — which brings us to the other option.
What actually works, option 2: precompute into an issue property
A Jira expression cannot call a model. It can, however, read entity properties — the docs list them as one of the "expensive operations", alongside comments and custom fields. So you can split the work in two: something asynchronous computes the verdict and writes it onto the issue, and the condition expression does nothing but read it.
I deployed exactly that and drove the property through three states on a live issue:
issue property lz.probe.verdict
transition listed?
transition executes?
absent
no
—
"fail"
no
—
"pass"
yes
POST → HTTP 204
4 rows × 3 columnsHeader row enabled
The gate follows the property, and it had already followed it within the two seconds I waited before re-reading. Note the first row: with no property set at all the expression does not come out true, and the transition is hidden. It fails closed by construction — the opposite default to a validator that fails open on a transient error, and worth knowing before you pick.
Write the property from wherever suits: a post-function on an earlier transition, an async event consumer, a scheduled trigger, or an external job holding an API token.
Note the quotes and the header. An entity property value must be valid JSON, so the string pass on its own is not acceptable — my first attempt sent a bare string and got HTTP 415 Unsupported Media Type: Content-Type 'text/plain;charset=UTF-8' is not supported, which is a confusing way to be told your body was not JSON.
Caution
The verdict is stale the moment it is written. The user edits the description, the button is still showing the answer to the previous version. If the thing you are gating on can change between the computation and the transition, pair this with a validator that re-checks at the moment of truth — the condition for the UX, the validator for the guarantee.
There is a second cost worth naming: the property is readable by anyone who can read the issue, and writable by anyone with edit permission and a REST client. It is a cache, not a security boundary. Treat a condition backed by one as a hint to the user interface, and put anything you actually need enforced in a validator.
What we changed in CogniRunner
Once the probe result was in, the rule type had to go. The fix was authoring-side and needed no manifest change:
The Condition option is gone from the add-rule wizard, which removes both the AI and the premade condition paths in one edit, since the condition sub-form is only reachable when the rule type is condition.
The editor's callout was rewritten. It used to say a failing condition "silently hides the transition" and "fails open on an AI error" — a plausible-sounding paragraph I wrote from the docs rather than from a test, and both halves were wrong about our own module. It now says the rule is deprecated and not enforced, and tells the admin to create a validator.
Existing conditions still render and still edit everywhere. Nobody's saved configuration disappears; the app just stops offering to create more.
I also found, writing this, that the project README still describes conditions as hiding a transition entirely. That sentence is true of Jira conditions in general and false of ours, which is the most dangerous kind of stale documentation. It is corrected in the repo now. The rest of what the app does — the validators, the semantic and static post-functions, the local-model providers — is covered here.
Reproduce it yourself
The whole probe is about forty lines. Everything below ran on a test site, in this order.
1
Write the manifest first, then register
put the two-module manifest.yml and the two-function src/index.js from above in an empty directory, then run forge register, which writes the app ID into the manifest you already have. Nothing else is needed; the app has no UI, no storage and no scopes.
2
Deploy and install
forge deploy -e development then forge install -e development --site <your-test-site>.atlassian.net --product jira. Watch the Validating manifest step accept the function key on the condition.
3
Confirm what Jira registered
GET /rest/api/3/workflows/capabilities?workflowId=<id> and look for "ruleKey": "forge:expression-condition" against your condition's ARI.
4
Attach both rules to the same status
add two self-loop transitions to a company-managed workflow via POST /rest/api/3/workflows/update and put one rule on each. Same status for both, or you are comparing two different things.
5
List, then force
GET /rest/api/3/issue/{key}/transitions and check which of the two came back. Then POST the missing transition's ID directly and read the status code.
6
Read the logs
forge logs -e development. Count the invocations. The condition's should be zero.
Do it on a sandbox. wolfaenpak is ours and exists for this; a company-managed workflow you edit over REST is not something to practise on in production.
The limitation I found
The thing I could not close is the surface question. I proved the behaviour on the REST transitions API, both listing and execution, and that is the surface that matters for automation, integrations and bulk operations. I did not drive the new issue-view transition menu in a browser, and I did not test a Jira Automation rule performing the transition. The documentation says the View issue page hides the button on a failing condition and I expect Automation to behave like any other server-side transition, but expecting is not measuring, and this whole article exists because we once shipped a callout built on exactly that distinction.
The other open edge: I tested company-managed projects only. Both modules accept projectTypes: [company-managed, team-managed], and team-managed workflows have their own rule surface. If you are building for team-managed, run the probe there before you trust any of this.
The general lesson is smaller and more annoying than the specific one. A Forge manifest is not a contract that gets checked. Unknown keys pass every gate the platform offers and then sit there looking load-bearing, and the only way to find out which of your modules are real is to make each one say something out loud and go read the logs.
The Forge types are not that contract either. A KVS condition comparing an integer attribute against the string "0" compiles clean and fails at runtime as a bare 422 with no property named, because the condition value is typed string | number | boolean and the platform is the only thing that checks which one you meant.