Setup-wizard input handling has a small surface but high stakes. The values it collects — firm name, staff roles, notes — drive every subsequent decision. The system prompt assembled at the start of every session reads from this data. The role-based logic that decides who can sign settlement letters reads from this data. Mistakes here cascade.

One specific failure shape kept catching my attention while probing: validation accepts something the storage layer doesn't actually normalize. The function checks "is this input acceptable?" and answers yes. The function that stores the value writes a slightly different version. Both functions look correct in isolation. The combination silently corrupts the stored data.

The wizard code:

let roleNum = await ask(rl, "...1/2/3/4: ");
while (!["1", "2", "3", "4"].includes(roleNum) &&
       !ROLES.map(r => r.toLowerCase()).includes(roleNum.toLowerCase())) {
  roleNum = await ask(rl, "Please enter 1-4: ");
}
const role = ROLES[parseInt(roleNum) - 1] || roleNum || "Staff";

The validation loop has two acceptance paths. The first matches the four numeric strings "1" through "4". The second matches any of the canonical role names ("Attorney", "Paralegal", "Admin/Staff", "System/Bot") case-insensitively. So "attorney", "Attorney", and "ATTORNEY" are all accepted.

The storage line picks a value via fallthrough. ROLES[parseInt("attorney") - 1]parseInt("attorney") is NaN, so the lookup is ROLES[NaN - 1] which is undefined. The fallthrough then takes roleNum itself, which is the literal string the user typed. So a user who types "attorney" gets their role stored as "attorney" — lowercase, not canonical.

Now consider what happens downstream. The system prompt has a section listing staff:

## STAFF ROLES (from local knowledge)
- Sample Attorney (SA) — user_id 1 — Attorney — attorney@aai.dev
- Sample Paralegal (SP) — user_id 2 — Paralegal — paralegal@example.com

Notice the inconsistent capitalization. Both are attorneys; one was added by typing "1", the other by typing "attorney". To a human reader those look the same. To the language model rendering the prompt, those look the same. To code that does staff.role === "Attorney", those are different strings. Any logic that depends on the exact canonical form misses the lowercase entry.

The fix

A small normalizer that runs after validation and before storage:

function normalizeRole(input) {
  if (!input) return "Staff";
  const trimmed = String(input).trim();
  // Numeric path
  const numeric = ROLES[parseInt(trimmed, 10) - 1];
  if (numeric) return numeric;
  // String path: case-insensitive match against canonical names
  const lc = trimmed.toLowerCase();
  for (const r of ROLES) {
    if (r.toLowerCase() === lc) return r;
  }
  return "Staff";
}

Now both call sites store the canonical form regardless of how the user typed it. "attorney", "Attorney", "ATTORNEY", and "1" all map to "Attorney". Eleven probe cases verified the mapping holds.

The pattern

This bug shape — validation accepts more shapes than storage canonicalizes — is common enough that I think it's worth naming. The validation function is asking "should I accept this?" The storage function is committing "this is what I'll persist." When the two operate at different levels of normalization, the persisted state can be a valid-but-uncanonical version of the input.

The fix is always the same: collapse validation and normalization into one step, or chain them tightly. Either reject the input that needs normalization (force the user to type exactly the canonical form), or normalize-then-validate (run the normalizer first, validate the normalized output). What you cannot do is accept-then-store-as-typed.

For setup-wizard inputs in particular, normalize-then-validate is the right pattern because the user types freely and the system has a small canonical vocabulary. Free-form text inputs (firm name, notes) get a sanitizer at the same boundary — strip control characters, cap length, then store the cleaned value. The principle is the same: never store a value that wasn't through the cleaning step.

The other two in the same probe

The probe round also surfaced two smaller issues in the same file:

Free-form fields accept newlines and unbounded length. The firm name and notes inputs went into knowledge.json verbatim. Embedded newlines, NUL bytes, and ten-thousand-character pastes all persisted. The prompt-render layer (added in an earlier release) sanitizes on read, so the prompt itself wasn't poisoned — but the raw saved data was, which causes issues for any future tool reading knowledge.json directly. Added a sanitizer at the input boundary.

Null-body parse in fetchUsers. Same null-body shape that's now been fixed four times in this codebase: data.data on a JSON-null body throws TypeError. The outer error handler caught it and surfaced "Merus returned no user data" — which pointed the user at the wrong remediation (check token, check permissions) when the real cause was "the API returned literal null". Added an explicit non-object guard with a typed error message naming the actual shape.

The version on the install URL is 4.5.98.