How to test which Mastra branch predicates fire when the path is misspelled
A misspelled path in a Mastra workflow branch predicate never errors. On @mastra/core 1.74.0 five of nine operators then skip the branch and four run it, so ne and not wrapping eq disagree on the same question. Four runnable scripts print the table, and the checker that would have caught it ships in the same package wired elsewhere.
On this page
Quick Answer (October 2026). On @mastra/core 1.74.0 a misspelled path in a workflow branch predicate never raises an error. It resolves to a missing-value sentinel, and what happens next depends on which operator you wrote. Across the nine operators I measured, five skip the branch and four run it. The run reports success either way. Two of those nine, ne and not wrapping eq, are two spellings of the same question, and on a path that does not resolve they disagree. Below are four scripts that print the whole table on your own install, plus the one-line grep that shows the checker which would have caught the typo is shipped in the same package and wired to a different part of it.
What the reference promises
Mastra workflows take branch conditions two ways. You can pass a closure, or you can pass a JSON object, which the engine calls a declarative predicate. The declarative form is the one that survives storage. The reference notes that stored loops require a declarative predicate, so it is what you get for a workflow loaded from a database row rather than defined in code. It is accepted by .branch(), .dowhile() and .dountil().
The behaviour on a path that cannot be resolved is documented. The dynamic workflow definition reference says this, under its Predicates heading:
Mastra reference, Predicates: "Missing paths don't throw. Path-based operators return false when the path can't be resolved. Use exists or notExists to distinguish a missing value from a falsy value."
The original renders false, exists and notExists as inline code, so that is the sentence with the code markup removed and nothing else changed. The same sentence is published at a second URL, /reference/workflows/stored-workflow-definition. The control flow guide, which is where most people learn .branch(), runs to 33,911 visible characters with navigation, header, sidebar and footer stripped, and contains the word predicate zero times.
Read that sentence closely, because the second half qualifies the first. It tells you to use notExists to detect a missing value, which means notExists must return true on a missing path. So the blanket claim in the first half cannot hold for every path-based operator, and the question becomes which ones it does hold for. That is a measurement, not a reading.
Set up a probe
Nothing here calls a model. A workflow step is a plain async function, so the whole investigation runs offline with no API key.
mkdir predicate-probe && cd predicate-probe
npm init -y
npm install --save-exact @mastra/core@1.74.0 zod@4.1.13
One: the operator table
This builds a workflow with one branch per operator. Every predicate asks the same question of the same field, inputData.priority, which is present and equal to urgent. The only thing that changes between the two runs is whether the path is spelled correctly. A conditional entry runs every branch whose predicate is true, so a single run tells you all nine answers at once. The always-true sentinel branch is there so the conditional always has one live branch and I am never testing two things at the same time.
const { createWorkflow, createStep } = require('@mastra/core/workflows');
const { z } = require('zod');
const GOOD = 'inputData.priority';
const TYPO = 'inputData.priorty';
// One branch per operator. Every predicate below asks the same question of the
// same field. The only difference between the two runs is whether the path is
// spelled correctly.
function cases(path) {
return [
['eq', { op: 'eq', left: { path: path }, right: { literal: 'urgent' } }],
['ne', { op: 'ne', left: { path: path }, right: { literal: 'urgent' } }],
['not_eq', { op: 'not', arg: { op: 'eq', left: { path: path }, right: { literal: 'urgent' } } }],
['in', { op: 'in', value: { path: path }, set: ['urgent'] }],
['notIn', { op: 'notIn', value: { path: path }, set: ['urgent'] }],
['exists', { op: 'exists', path: path }],
['notExists', { op: 'notExists', path: path }],
['truthy', { op: 'truthy', value: { path: path } }],
['falsy', { op: 'falsy', value: { path: path } }],
];
}
function mark(id) {
return createStep({
id: id,
inputSchema: z.object({ priority: z.string() }),
outputSchema: z.object({ ran: z.string() }),
execute: async function () { return { ran: id }; },
});
}
async function runArm(label, path) {
const list = cases(path);
const branches = list.map(function (pair) { return [{ predicate: pair[1] }, mark(pair[0])]; });
// A sentinel that is always true, so the conditional always has one live branch.
branches.push([{ predicate: { op: 'truthy', value: { literal: true } } }, mark('sentinel')]);
const wf = createWorkflow({
id: 'route-' + label,
inputSchema: z.object({ priority: z.string() }),
outputSchema: z.any(),
}).branch(branches).commit();
const run = await wf.createRun();
const res = await run.start({ inputData: { priority: 'urgent' } });
const fired = {};
const steps = (res && res.steps) || {};
list.forEach(function (pair) {
const s = steps[pair[0]];
fired[pair[0]] = Boolean(s && s.status === 'success');
});
return { status: res.status, fired: fired, sentinel: Boolean(steps.sentinel) };
}
(async function () {
const good = await runArm('good', GOOD);
const typo = await runArm('typo', TYPO);
console.log('mastra core : ' + require('@mastra/core/package.json').version);
console.log('field present : inputData.priority = "urgent"');
console.log('correct path : ' + GOOD);
console.log('typo path : ' + TYPO + ' (one letter dropped)');
console.log('run status : good=' + good.status + ' typo=' + typo.status);
console.log('sentinel ran : good=' + good.sentinel + ' typo=' + typo.sentinel);
console.log('');
console.log('operator correct path typo path');
console.log('------------------------------------------------------');
// The docs promise one answer for every path-based operator on an
// unresolvable path: false, which means the branch is skipped.
Object.keys(good.fired).forEach(function (op) {
const g = good.fired[op] ? 'RAN ' : 'skip';
const t = typo.fired[op] ? 'RAN ' : 'skip';
const flag = typo.fired[op] ? ' docs say skip' : '';
console.log(op.padEnd(11) + ' ' + g + ' ' + t + flag);
});
process.exit(0);
})().catch(function (e) { console.error('FAILED', e && e.message); process.exit(1); });
mastra core : 1.74.0
field present : inputData.priority = "urgent"
correct path : inputData.priority
typo path : inputData.priorty (one letter dropped)
run status : good=success typo=success
sentinel ran : good=true typo=true
operator correct path typo path
------------------------------------------------------
eq RAN skip
ne skip skip
not_eq skip RAN docs say skip
in RAN skip
notIn skip RAN docs say skip
exists RAN skip
notExists skip RAN docs say skip
truthy RAN skip
falsy skip RAN docs say skip
Five operators skip the branch on the misspelled path, which is what the reference sentence promises. Four run it.
One of those four is fine. notExists is the operator the reference explicitly tells you to reach for when you want to detect a missing value, so returning true is exactly its job, and exists returning false is the same statement from the other side. Those two are not the finding. They are the pair the sentence itself carves out.
The other three are the finding. notIn, falsy, and not wrapping anything all run the branch on a path that does not resolve, and no sentence anywhere in the reference says they will.
The sharpest consequence is in the first two rows plus the third. ne and not wrapping eq are the same logical predicate written two ways. On the correct path both skip, because priority really is urgent. On the misspelled path ne skips and not runs. Two spellings of not-equal agree whenever the path resolves and part company exactly when it does not, which is the one case you were not watching.
The practical version: a typo does not make your routing fail in a predictable direction. Whether the guard fails open or fails closed is decided by which operator you happened to write, so you cannot reason about it without the table.
Two: it is the same for a root you invented, and the stored label still looks healthy
The predicate resolver is a switch over four root names. Anything else falls through to its default, which is the missing sentinel. So a wrong root behaves the same as a wrong leaf, and the second thing this script prints is the label the engine stores for the conditional, which is what a dashboard or a visual editor renders.
const { createWorkflow, createStep } = require('@mastra/core/workflows');
const { z } = require('zod');
// Four spellings of the same intent. Only the first is a root the resolver knows.
const PATHS = [
['inputData.priority', 'correct'],
['inputData.priorty', 'typo in the leaf'],
['inputdata.priority', 'typo in the root, lowercase d'],
['input.priority', 'wrong root name'],
['stepResult.x.y', 'wrong root name, singular'],
];
function mark(id) {
return createStep({
id: id,
inputSchema: z.object({ priority: z.string() }),
outputSchema: z.object({ ran: z.string() }),
execute: async function () { return { ran: id }; },
});
}
(async function () {
console.log('operator pair: eq / notExists, same path each time');
console.log('');
console.log('path label eq notExists');
console.log('-------------------------------------------------------------------------------');
for (const entry of PATHS) {
const path = entry[0];
const branches = [
[{ predicate: { op: 'eq', left: { path: path }, right: { literal: 'urgent' } } }, mark('eq')],
[{ predicate: { op: 'notExists', path: path } }, mark('notExists')],
[{ predicate: { op: 'truthy', value: { literal: true } } }, mark('sentinel')],
];
const wf = createWorkflow({
id: 'r-' + Buffer.from(path).toString('hex').slice(0, 10),
inputSchema: z.object({ priority: z.string() }),
outputSchema: z.any(),
}).branch(branches).commit();
const run = await wf.createRun();
const res = await run.start({ inputData: { priority: 'urgent' } });
const steps = (res && res.steps) || {};
const ranEq = Boolean(steps.eq && steps.eq.status === 'success');
const ranNe = Boolean(steps.notExists && steps.notExists.status === 'success');
// The label the engine stores for the conditional, as a dashboard would show it.
const entryObj = wf.serializedStepFlow.find(function (e) { return e.type === 'conditional'; });
const label = entryObj.serializedConditions[0].fn;
console.log(path.padEnd(23) + ' ' + label.padEnd(37) + ' ' +
(ranEq ? 'RAN ' : 'skip ') + ' ' + (ranNe ? 'RAN' : 'skip') +
' status=' + res.status);
}
console.log('');
console.log('roots the workflow resolver knows: initData, inputData, state, stepResults');
process.exit(0);
})().catch(function (e) { console.error('FAILED', e && e.message); process.exit(1); });
operator pair: eq / notExists, same path each time
path label eq notExists
-------------------------------------------------------------------------------
inputData.priority inputData.priority == "urgent" RAN skip status=success
inputData.priorty inputData.priorty == "urgent" skip RAN status=success
inputdata.priority inputdata.priority == "urgent" skip RAN status=success
input.priority input.priority == "urgent" skip RAN status=success
stepResult.x.y stepResult.x.y == "urgent" skip RAN status=success
roots the workflow resolver knows: initData, inputData, state, stepResults
input.priority is not a root this resolver knows, and the stored label reads input.priority == "urgent", which is indistinguishable from a working condition. Nothing in the rendered label marks the path as unresolvable, because the label is derived from the predicate text and never from the context it will be evaluated against.
Three: a root that works in a template and not in a predicate
This one needs no typo at all, which is why I think it is the likeliest way to meet the behaviour in real code. A mapping entry resolves its template placeholders against five roots, and the reference lists them as initData, inputData, state, requestContext and stepResults. A predicate resolves against four, and the reference lists those too, one paragraph further down: initData, inputData, stepResults and state. requestContext is on the first list and not the second.
const { createWorkflow, createStep } = require('@mastra/core/workflows');
const { RequestContext } = require('@mastra/core/request-context');
const { z } = require('zod');
function mark(id) {
return createStep({
id: id,
inputSchema: z.any(),
outputSchema: z.object({ ran: z.string() }),
execute: async function () { return { ran: id }; },
});
}
// requestContext is a valid root for a mapping template. It is not one of the
// four roots the predicate resolver switches on.
(async function () {
const wf = createWorkflow({
id: 'rc',
inputSchema: z.object({ priority: z.string() }),
outputSchema: z.any(),
}).branch([
[{ predicate: { op: 'eq', left: { path: 'requestContext.tenant' }, right: { literal: 'acme' } } }, mark('by-request-context')],
[{ predicate: { op: 'eq', left: { path: 'inputData.priority' }, right: { literal: 'urgent' } } }, mark('by-input-data')],
]).commit();
const rc = new RequestContext();
rc.set('tenant', 'acme');
const run = await wf.createRun();
const res = await run.start({ inputData: { priority: 'urgent' }, requestContext: rc });
const steps = res.steps || {};
console.log('requestContext.tenant was set to "acme" on the run');
console.log('');
console.log('predicate path branch');
console.log('------------------------------------');
console.log('requestContext.tenant ' + (steps['by-request-context'] && steps['by-request-context'].status === 'success' ? 'RAN' : 'skip'));
console.log('inputData.priority ' + (steps['by-input-data'] && steps['by-input-data'].status === 'success' ? 'RAN' : 'skip'));
console.log('');
console.log('run status: ' + res.status);
process.exit(0);
})().catch(function (e) { console.error('FAILED', e && e.message); process.exit(1); });
requestContext.tenant was set to "acme" on the run
predicate path branch
------------------------------------
requestContext.tenant skip
inputData.priority RAN
run status: success
The value is set on the run. The predicate comparing it to the value it holds is false. Both lists in the reference are accurate; a reader who carries a path from a template into a predicate is the one who pays.
Four: when no branch matches, the workflow walks straight past
Every arm so far had a sentinel branch. Take it away and give the conditional two real branches, both gated on the same misspelled path, both using operators that skip.
const { createWorkflow, createStep } = require('@mastra/core/workflows');
const { z } = require('zod');
function mark(id) {
return createStep({
id: id,
inputSchema: z.object({ priority: z.string() }),
outputSchema: z.object({ ran: z.string() }),
execute: async function () { return { ran: id }; },
});
}
const after = createStep({
id: 'after',
inputSchema: z.any(),
outputSchema: z.object({ reached: z.boolean() }),
execute: async function () { return { reached: true }; },
});
// Two branches, both gated on the SAME misspelled path, both using operators
// that the docs promise return false on an unresolvable path. So no branch
// should run at all.
async function arm(label, path) {
const wf = createWorkflow({
id: 'nm-' + label,
inputSchema: z.object({ priority: z.string() }),
outputSchema: z.any(),
}).branch([
[{ predicate: { op: 'eq', left: { path: path }, right: { literal: 'urgent' } } }, mark('escalate')],
[{ predicate: { op: 'in', value: { path: path }, set: ['low', 'normal'] } }, mark('auto-reply')],
]).then(after).commit();
const run = await wf.createRun();
let status = 'unknown';
let res = null;
try {
res = await run.start({ inputData: { priority: 'urgent' } });
status = res.status;
} catch (e) {
status = 'threw: ' + (e && e.message);
}
const steps = (res && res.steps) || {};
return {
status: status,
escalate: Boolean(steps.escalate && steps.escalate.status === 'success'),
autoReply: Boolean(steps['auto-reply'] && steps['auto-reply'].status === 'success'),
after: Boolean(steps.after && steps.after.status === 'success'),
};
}
(async function () {
const good = await arm('good', 'inputData.priority');
const typo = await arm('typo', 'inputData.priorty');
console.log('two branches, no default, no sentinel');
console.log('');
console.log('path escalate auto-reply next step run status');
console.log('----------------------------------------------------------------------');
[['inputData.priority', good], ['inputData.priorty', typo]].forEach(function (pair) {
const r = pair[1];
console.log(pair[0].padEnd(21) + ' ' +
(r.escalate ? 'RAN ' : 'skip ') + ' ' +
(r.autoReply ? 'RAN ' : 'skip ') + ' ' +
(r.after ? 'RAN ' : 'skip ') + ' ' + r.status);
});
process.exit(0);
})().catch(function (e) { console.error('FAILED', e && e.message); process.exit(1); });
two branches, no default, no sentinel
path escalate auto-reply next step run status
----------------------------------------------------------------------
inputData.priority RAN skip RAN success
inputData.priorty skip skip RAN success
Neither branch runs, the following step runs anyway, and the run reports success. The conditional became a no-op.
It is worth knowing that this is a design choice rather than the only option, because a neighbouring product in the same category made the opposite one. The AWS Step Functions Choice state reference says, with its inline emphasis markup removed:
AWS Step Functions, Choice workflow state: "If no Choices evaluate to true when the workflow runs, and no Default is provided, the state machine will throw an error due to a failure to transition out of the state."
I did not run Step Functions, so treat that as a quotation of its documentation and not as a measurement. The point is only that an all-false conditional is a condition a workflow engine can choose to treat as an error, and this one does not.
Five: a misspelled loop predicate does not terminate
dountil repeats a step until its predicate holds. If the predicate can never be true, nothing in the engine stops the loop. The step below counts its own invocations and throws at 25 so this script cannot hang your shell.
const { createWorkflow, createStep } = require('@mastra/core/workflows');
const { z } = require('zod');
const CAP = 25; // hard stop so a non-terminating loop cannot hang this script
function poll(counter) {
return createStep({
id: 'poll',
inputSchema: z.object({ status: z.string() }),
outputSchema: z.object({ status: z.string() }),
execute: async function () {
counter.n = counter.n + 1;
if (counter.n === 1) return { status: 'pending' };
if (counter.n === 2) return { status: 'done' };
// Math.max avoids a comparison operator; true once counter.n reaches CAP.
if (Math.max(counter.n, CAP) === counter.n) throw new Error('hard stop after ' + CAP + ' iterations');
return { status: 'done' };
},
});
}
async function arm(label, path) {
const counter = { n: 0 };
const wf = createWorkflow({
id: 'poll-' + label,
inputSchema: z.object({ status: z.string() }),
outputSchema: z.any(),
}).dountil(poll(counter), {
predicate: { op: 'eq', left: { path: path }, right: { literal: 'done' } },
}).commit();
const run = await wf.createRun();
let status = 'unknown';
try {
const res = await run.start({ inputData: { status: 'pending' } });
status = res.status;
} catch (e) {
status = 'threw: ' + (e && e.message);
}
return { iterations: counter.n, status: status };
}
(async function () {
const good = await arm('good', 'inputData.status');
const typo = await arm('typo', 'inputData.sttaus');
console.log('dountil poll, terminates when status == "done"');
console.log('step returns "pending" once, then "done"');
console.log('');
console.log('predicate path iterations run status');
console.log('---------------------------------------------------');
console.log('inputData.status '.padEnd(24) + String(good.iterations).padEnd(12) + good.status);
console.log('inputData.sttaus '.padEnd(24) + String(typo.iterations).padEnd(12) + typo.status);
console.log('');
console.log('hard stop was set at ' + CAP + ' iterations by the step itself, not by the engine');
process.exit(0);
})().catch(function (e) { console.error('FAILED', e && e.message); process.exit(1); });
dountil poll, terminates when status == "done"
step returns "pending" once, then "done"
predicate path iterations run status
---------------------------------------------------
inputData.status 2 success
inputData.sttaus 25 failed
hard stop was set at 25 iterations by the step itself, not by the engine
The correct path terminates in two iterations. The misspelled one ran until my own step refused to continue. The 25 in that table is my hard stop, not a bound the engine imposed, and without it the loop does not end. A typo in a poll loop is therefore a different class of problem from a typo in a branch.
Why it behaves this way
The resolver walks a dotted path with a non-throwing traversal and returns a sentinel when any segment is absent. The traversal tests each segment with Object.hasOwn, which per MDN "returns true if the specified object has the indicated property as its own property. If the property is inherited, or does not exist, the method returns false." An absent key therefore produces the sentinel rather than undefined, which is what lets exists and notExists tell a missing value apart from a null one.
The sentinel then meets the operator, and that is where the asymmetry comes from. A comparison that cannot be made is reported as false. A negation of a comparison that cannot be made is reported as true, because the negation is applied to the false, not to the failure. Both are defensible in isolation. Together they mean a single unresolvable path produces opposite routing depending on how you spelled the condition.
The checker exists, and it is pointed somewhere else
This is the part I did not expect. The same package exports a function that validates predicate paths against a list of known roots. It is correct, it is public, and the workflow path never calls it.
const { collectInvalidPredicatePaths } = require('@mastra/core/predicate');
const { SCORING_PREDICATE_ROOTS } = require('@mastra/core/evals');
// The four roots the workflow predicate resolver switches on. Read from
// the resolver, not guessed: anything else falls through to its default.
const WORKFLOW_ROOTS = ['initData', 'inputData', 'state', 'stepResults'];
const SUSPECTS = [
{ op: 'eq', left: { path: 'inputData.priority' }, right: { literal: 'urgent' } },
{ op: 'eq', left: { path: 'inputData.priorty' }, right: { literal: 'urgent' } },
{ op: 'eq', left: { path: 'inputdata.priority' }, right: { literal: 'urgent' } },
{ op: 'eq', left: { path: 'input.priority' }, right: { literal: 'urgent' } },
{ op: 'notExists', path: 'stepResult.x.y' },
];
console.log('scoring roots : ' + SCORING_PREDICATE_ROOTS.join(', '));
console.log('workflow roots : ' + WORKFLOW_ROOTS.join(', '));
console.log('');
console.log('predicate path invalid under workflow roots?');
console.log('------------------------------------------------------');
SUSPECTS.forEach(function (p) {
const path = p.path || p.left.path;
const bad = collectInvalidPredicatePaths(p, WORKFLOW_ROOTS);
console.log(path.padEnd(23) + ' ' + (bad.length ? 'INVALID ' + JSON.stringify(bad) : 'valid root'));
});
console.log('');
console.log('The checker is exported from @mastra/core/predicate and it is correct.');
console.log('Nothing in the workflow path calls it.');
process.exit(0);
scoring roots : requestContext, entity, entityType, source, threadId, resourceId, projectId
workflow roots : initData, inputData, state, stepResults
predicate path invalid under workflow roots?
------------------------------------------------------
inputData.priority valid root
inputData.priorty valid root
inputdata.priority INVALID ["inputdata.priority"]
input.priority INVALID ["input.priority"]
stepResult.x.y INVALID ["stepResult.x.y"]
The checker is exported from @mastra/core/predicate and it is correct.
Nothing in the workflow path calls it.
Point it at the four workflow roots and it flags every invented root I tried. Then find out who actually calls it:
grep -rn 'collectInvalidPredicatePaths(' node_modules/@mastra/core/dist \
--include='*.js' --include='*.cjs' | grep -v 'function collectInvalidPredicatePaths'
node_modules/@mastra/core/dist/agent-CnrRF71P.js:3337: const invalid = collectInvalidPredicatePaths(pred, SCORING_PREDICATE_ROOTS);
node_modules/@mastra/core/dist/agent-C-4VraMN.cjs:3339: const invalid = require_predicate_index.collectInvalidPredicatePaths(pred, SCORING_PREDICATE_ROOTS);
One invocation in the ESM build, one in the CommonJS build, and both pass the scoring roots. Scoring filters are root-checked when they are registered. Workflow predicates are not checked at all. The comment above the scoring validator in the shipped bundle says it throws so that "typos fail loud when the binding is registered instead of silently skipping all scoring at runtime", which is precisely the failure mode a workflow branch has.
Two honest limits on that. First, the checker only validates the root, so it would have caught inputdata.priority and input.priority and would not have caught inputData.priorty. You can see that in its output above: the leaf typo is reported as a valid root. Second, the reference's own Validation section lists what is checked before a definition is persisted, and it names three things, Structure, References and Schema flow. Predicate paths are not on that list, so the documentation is consistent with what I measured. It does not claim a check that is missing.
The four-step test, on your own workflow
The transferable part is not the Mastra result. It is that any declarative predicate or filter DSL has to decide what a negation does when its operand cannot be evaluated, and that decision is rarely in the operator table you were given. So:
- Take one real predicate from your own workflow and break the path by one character.
- Run it and record whether the branch fires, not whether an error appears.
- Repeat for every operator you actually use, and separately for the negated form of each one. If your DSL has both
neandnot, test both; they are the pair most likely to diverge. - Check whether your definition-time validation covers predicate paths, by grepping for the validator rather than by trusting that one exists.
Until step four passes, the cheap mitigation is to stop writing paths by hand. Build them from the same constant your step schema uses, so a rename moves both at once and a typo is a reference error in your editor instead of a routing decision at runtime.
What I did not measure
Worth saying plainly, because each of these would change the advice.
- I did not test whether a value on an object's prototype chain reads as missing, even though the MDN sentence above suggests it would. A predicate path pointing at an inherited property is a real possibility and I have no measurement for it.
- I did not run AWS Step Functions. The comparison above is a quotation of its reference.
- I did not test the stored and rehydrated path, only predicates passed in process to
.branch()and.dountil(). A workflow loaded from a database row goes through rehydration first, and I cannot say from this work whether anything validates paths there. - I did not test whether a leaf path could in principle be checked against a step's
inputSchemaat definition time. It looks possible, since the schemas are right there, but that is a guess about an implementation and not a result. - I measured one version, 1.74.0, on Node 24.8.0. I did not bisect, so I cannot tell you when any of this started.
Written by
Sofia NievesSofia works on agent evaluation and reliability. She writes about measuring LLM systems before and after they reach production.
Frequently asked questions
Does a misspelled path in a Mastra branch predicate raise an error?
No. On @mastra/core 1.74.0 the path resolver returns a missing-value sentinel instead of throwing, and the run reports success. In the measurement above both the correct-path run and the misspelled-path run finished with status success, and the only difference was which branches executed.
Which operators run the branch when the path cannot be resolved?
Of the nine measured, four run it: not wrapping another operator, notIn, notExists and falsy. Five skip it: eq, ne, in, exists and truthy. The notExists and exists pair is intended, because the reference tells you to use them to detect a missing value. The unflagged three are notIn, falsy and not.
Why do ne and not wrapping eq give different answers?
They are the same logical predicate but the negation is applied at different points. For ne, the resolver cannot produce a left operand so the comparison itself is reported as false and the branch is skipped. For not wrapping eq, the inner eq is reported as false and then the not inverts that false into true, so the branch runs. The negation is applied to the false, not to the failure.
Does the Mastra documentation describe this?
Partly, and in the wrong direction for three operators. The dynamic workflow definition reference says that missing paths do not throw and that path-based operators return false when the path cannot be resolved. That is accurate for five of the nine operators. The same sentence then tells you to use notExists to distinguish a missing value, which only works if notExists returns true, so the sentence qualifies itself. Nothing documents notIn, falsy or not.
Is there anything that validates predicate paths before the workflow runs?
A validator exists and is exported as collectInvalidPredicatePaths from @mastra/core/predicate, but grepping the shipped bundle shows its only invocation in each build passes the scoring roots. Workflow predicate paths are not checked. The reference's Validation section lists Structure, References and Schema flow, and predicate paths are not among them.
Would that validator have caught my typo?
Only if the typo is in the root. It validates that a path starts with a known root, so it flags inputdata.priority and input.priority and does not flag inputData.priorty. A misspelled leaf under a correct root stays undetectable at definition time by that function.
What happens to a dountil loop with a misspelled predicate?
It does not terminate. The predicate can never become true, and the engine imposes no iteration bound. In the measurement the correct path finished in two iterations and the misspelled path ran until the step itself threw at a hard stop of 25 that the script sets, not the engine.
Why does requestContext not work in a predicate?
Because the predicate resolver switches over four roots only: initData, inputData, state and stepResults. A mapping template resolves against five, including requestContext. Both lists are published accurately in the reference, one paragraph apart, so a path carried from a template into a predicate resolves to the missing sentinel with no error.
Related tutorials
processOutputResult runs on stream(), and the raw text already reached the reader
A Mastra output processor that implements only processOutputResult does run on agent.stream(), but it runs after every chunk has been delivered. The redaction lands on result.text and never reaches the reader. Measured on @mastra/core 1.72.0 with no API key.
Mastra rewrites tool object keys, and toolChoice does not follow
Mastra documents that the object key, not the tool id, becomes the name the model sees. It does not document that the key is rewritten first: invalid characters become underscores and the result is cut to 63. The tools array goes through that rewrite. toolChoice does not.
Your Zod .refine() never reaches the model
Measured on ai 7.0.118 and zod 4.6.5: a .refine() on a tool input schema is stripped before the schema reaches the model, with no warning. z.string().startsWith('acct_') arrives as a pattern; the same rule written as a refinement arrives as nothing.