Agent frameworks
Sofia Nieves12 min read140 views

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.

Flat printed schematic on off-white paper. A charcoal line enters from the left through an empty rectangular outline plate, then fans out at a junction into six slate blue rails. Two rails carry a small solid amber rectangle; the other four run bare. No text.
Flat printed schematic on off-white paper. A charcoal line enters from the left through an empty rectangular outline plate, then fans out at a junction into six slate blue rails. Two rails carry a small solid amber rectangle; the other four run bare. No text.
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.

TypeScript Node.js Zod npm

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.

bash
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.

javascript
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); });
text
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.

javascript
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); });
text
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.

javascript
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); });
text
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.

javascript
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); });
text
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.

javascript
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); });
text
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.

javascript
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);
text
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:

bash
grep -rn 'collectInvalidPredicatePaths(' node_modules/@mastra/core/dist \
  --include='*.js' --include='*.cjs' | grep -v 'function collectInvalidPredicatePaths'
text
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:

  1. Take one real predicate from your own workflow and break the path by one character.
  2. Run it and record whether the branch fires, not whether an error appears.
  3. Repeat for every operator you actually use, and separately for the negated form of each one. If your DSL has both ne and not, test both; they are the pair most likely to diverge.
  4. 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 inputSchema at 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.
Sofia Nieves

Written by

Sofia Nieves

Sofia 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.

Tutorials

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.

11 min read129
Tutorials

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.

11 min read161