mastra-ai/mastra · error · FactoryRuleValidationError

${label}.${stage}.${source} handlers must be functions.

Error message

${label}.${stage}.${source} handlers must be functions.

What it means

The onEnter/onExit fields of a stage+source leaf must be functions or omitted (undefined). This error fires when either handler value is present but not callable — the library cannot invoke it during board transitions.

Source

Thrown at mastracode/factory/src/rules/validation.ts:153

  if (!isPlainObject(sanitized)) throw new FactoryRuleValidationError('Rule metadata must be an object.');
  if (JSON.stringify(sanitized).length > MAX_METADATA_JSON_LENGTH) {
    throw new FactoryRuleValidationError('Rule metadata is too large.');
  }
  return sanitized;
}

function validateBoardRules(rules: unknown, label: string): asserts rules is FactoryBoardRules {
  if (!isPlainObject(rules)) throw new FactoryRuleValidationError(`${label} must be an object.`);
  for (const [stage, sources] of Object.entries(rules)) {
    enumValue(stage, FACTORY_RULE_STAGES, `${label} stage`);
    if (!isPlainObject(sources)) throw new FactoryRuleValidationError(`${label}.${stage} must be an object.`);
    for (const [source, leaf] of Object.entries(sources)) {
      enumValue(source, FACTORY_RULE_SOURCES, `${label}.${stage} source`);
      if (!isPlainObject(leaf)) throw new FactoryRuleValidationError(`${label}.${stage}.${source} must be an object.`);
      assertExactKeys(leaf, ['onEnter', 'onExit'], `${label}.${stage}.${source}`);
      for (const handler of Object.values(leaf)) {
        if (handler !== undefined && typeof handler !== 'function') {
          throw new FactoryRuleValidationError(`${label}.${stage}.${source} handlers must be functions.`);
        }
      }
    }
  }
}

export function assertFactoryRules(rules: unknown): asserts rules is FactoryRules {
  if (!isPlainObject(rules)) throw new FactoryRuleValidationError('Factory rules must be an object.');
  assertExactKeys(rules, ['version', 'work', 'review', 'tools', 'github', 'linear'], 'Factory rules');
  boundedString(rules.version, 'Factory rule version', MAX_VERSION_LENGTH);
  validateBoardRules(rules.work, 'Factory rules.work');
  validateBoardRules(rules.review, 'Factory rules.review');

  if (!isPlainObject(rules.tools)) throw new FactoryRuleValidationError('Factory rules.tools must be an object.');
  for (const [toolName, leaf] of Object.entries(rules.tools)) {
    boundedString(toolName, 'Factory tool name', 128, IDENTIFIER_RE);
    if (!isPlainObject(leaf))
      throw new FactoryRuleValidationError(`Factory rules.tools.${toolName} must be an object.`);

View on GitHub (pinned to 75dd419e61)

Solutions

  1. Pass the function reference itself, not a call result or a string name.
  2. If rules must be serialized, store identifiers and resolve them to functions at load time before calling assertFactoryRules.
  3. Remove the key entirely if no handler is needed (undefined is allowed).

Example fix

// before
work: { plan: { 'github-pr': { onEnter: 'onPlanEnter' } } }
// after
work: { plan: { 'github-pr': { onEnter: onPlanEnter } } }
Defensive patterns

Strategy: validation

Validate before calling

for (const [stage, sources] of Object.entries(rules.work ?? {})) {
  for (const [source, leaf] of Object.entries(sources ?? {})) {
    for (const key of ['onEnter', 'onExit']) {
      const h = leaf?.[key];
      if (h !== undefined && typeof h !== 'function') {
        throw new Error(`work.${stage}.${source}.${key} must be a function or omitted`);
      }
    }
  }
}

Type guard

const isHandler = (v: unknown): v is ((...args: never[]) => unknown) | undefined =>
  v === undefined || typeof v === 'function';

Try / catch

try {
  assertFactoryRules(rules);
} catch (e) {
  if (e.name === 'FactoryRuleValidationError' && /handlers must be functions/.test(e.message)) {
    console.error(`Non-function handler: ${e.message}`); // resolve names/strings to functions before validating
  } else throw e;
}

Prevention

When it happens

Trigger: Setting rules.work.<stage>.<source>.onEnter or .onExit to a string naming a function, an async result, a Promise, an object, or a number instead of a function, via assertFactoryRules.

Common situations: Serializing rules to JSON (functions become strings or are lost) and reloading them; assigning a thunk `myFn()` result instead of `myFn`; config-driven rules where handlers are referenced by name but never resolved.

Related errors


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/6aa6c604187160e8. Report an issue: GitHub.