santifer/career-ops · critical · Error

Malformed states file at ${statesPath}: expected a top-level

Error message

Malformed states file at ${statesPath}: expected a top-level "states" list

What it means

loadLifecycle() in tracker-sync-check.mjs reads templates/states.yml (the canonical source for tracker states and lifecycle order), parses it with the yaml library, and expects a top-level `states` array. If the document is null, not an object, or lacks an array `states` key, it throws an Error identifying the file. This is a config-integrity guard.

Source

Thrown at tracker-sync-check.mjs:113

// against every other status) instead of correctly ranking it as terminal.
//
// states.yml has no explicit ordering field, so LIFECYCLE_ORDER is taken from
// the file's own array order among states NOT marked `terminal: true`; the
// terminal set and the id -> display-label map are read directly off each
// state's `terminal` and `label` fields. See the comment above `states:` in
// templates/states.yml for the contract.
const STATES_FILE = join(CAREER_OPS, 'templates/states.yml');

/**
 * Load the canonical lifecycle order, terminal-status set, and id -> label
 * map from templates/states.yml.
 * @param {string} statesPath - Path to templates/states.yml.
 * @returns {{ order: string[], terminal: Set<string>, labels: Record<string,string> }}
 */
export function loadLifecycle(statesPath) {
  const doc = yaml.load(readFileSync(statesPath, 'utf-8'));
  if (!doc || !Array.isArray(doc.states)) {
    throw new Error(`Malformed states file at ${statesPath}: expected a top-level "states" list`);
  }
  const order = [];
  const terminal = new Set();
  const labels = {};
  for (const s of doc.states) {
    const id = String(s?.id ?? '').trim();
    if (!id) continue;
    labels[id] = String(s.label ?? id);
    if (s.terminal) terminal.add(id);
    else order.push(id);
  }
  return { order, terminal, labels };
}

const { order: LIFECYCLE_ORDER, terminal: TERMINAL_STATUSES, labels: CANONICAL_LABELS } = loadLifecycle(STATES_FILE);

// Mirrors the ALIASES map in analyze-patterns.mjs / verify-pipeline.mjs —
// applications.md status cell normalization (bold markers, trailing dates,

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Restore states.yml from the repo: git checkout HEAD -- templates/states.yml.
  2. Re-run node update-system.mjs apply to restore system files.
  3. Validate the YAML: ensure it has `states:` as a top-level key whose value is a list of objects with id/label/terminal fields.
  4. Run node verify-pipeline.mjs after restoring to confirm integrity.

Example fix

# before (broken states.yml)
statuses:
  - id: applied

# after (correct schema)
states:
  - id: Applied
    label: Applied
    terminal: false
Defensive patterns

Strategy: validation

Validate before calling

function validateStatesYml(statesPath) {
  const doc = yaml.load(readFileSync(statesPath, 'utf-8'));
  if (!doc || !Array.isArray(doc.states)) {
    throw new Error(`states.yml at ${statesPath} is missing a top-level 'states' list`);
  }
  return doc;
}
validateStatesYml(STATES_FILE);

Try / catch

try {
  const lifecycle = loadLifecycle(STATES_FILE);
} catch (err) {
  if (err.message.includes('Malformed states file')) {
    console.error(`${err.message}\nRestore: git checkout HEAD -- templates/states.yml`);
    process.exit(1);
  }
  throw err;
}

Prevention

When it happens

Trigger: templates/states.yml is empty, contains valid YAML that is not an object (e.g. a bare scalar or list), or has a different top-level key (e.g. `statuses` instead of `states`). Hand-editing the file and breaking its structure triggers this.

Common situations: A user hand-edits states.yml and accidentally changes the top-level key or truncates the file; a merge conflict left unresolved or resolved incorrectly; a tool rewrote the YAML with a different schema.

Understand the failure class

Related errors


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/e4eee8f2aaf7744f. Report an issue: GitHub.