An attorney asks the system whether a phone number is on any case in the firm. The number is a 10-digit string: 3105550100. The skill runs, scans about two thousand contact records, and reports nothing matches.

The attorney is sure the number is in the system. The number is the applicant’s, on a case the attorney handled six months ago.

The skill was looking in the wrong place — but it was looking in the only place it knew about.

What the index actually returns

The contact-lookup skill, as written before this fix, walked one endpoint:

/contacts/index   → { contact_id: [last_name, first_name], ... }

That’s the entire payload. Positional records, two fields per contact: last name at index 0, first name at index 1. About two thousand records for this firm. Fast to fetch, fast to scan.

There is no phone field. There is no email field. There is no address field. The flat index is a name-only catalog; everything else lives one level down, in the deep contact view (/contacts/view/N) or in the case file view (/caseFiles/view/N) where the contact appears as a party.

A name-based lookup against the flat index is fast and correct. A phone-based lookup against the flat index is fast and useless — the field being searched doesn’t exist in the records being scanned.

Where the phone actually lives

Walked the case file view for the case the attorney remembered. The phone was sitting at this path:

/caseFiles/view/[CASE-ID]
  → CaseFile.Applicant.Contact.Phone[0].digits
    = "310-555-0100"

Also surfaced on a rollup path the API generates for display:

  → CaseDetailsDisplayParty.Phone[0].digits
    = "310-555-0100"

Same value, two paths. The deep view record is where the contact information actually lives. The flat index just doesn’t carry it.

This isn’t a Merus quirk. It’s a routine REST tradeoff: the index endpoint returns slim records to keep listing pages fast, and the detail endpoint returns the full structure. The cost is that anything not on the slim record is invisible to anyone searching only the index.

The second phase

The fix is a deep walk: for every case in the firm, fetch the case file view, recursively traverse every nested string field, and check each one against the query.

function* walkStrings(obj, path = '') {
  if (typeof obj === 'string') { yield { path, value: obj }; return; }
  if (Array.isArray(obj)) {
    for (let i = 0; i < obj.length; i++)
      yield* walkStrings(obj[i], path + '[' + i + ']');
    return;
  }
  if (typeof obj === 'object' && obj !== null) {
    for (const k of Object.keys(obj))
      yield* walkStrings(obj[k], path ? path + '.' + k : k);
  }
}

Normalize the query by shape: strip all non-digit characters for a phone, lowercase for an email, NFD-decompose + strip non-alphanumeric for a name. Normalize the candidate the same way before comparison. So (310) 555-0100, 310.555.0100, +1-310-555-0100, and 3105550100 all match.

The walk runs across every case in concurrent batches of ten. For this firm (about 220 cases), wall-clock is 30 to 60 seconds depending on upstream latency.

What the attorney sees

The same query the skill couldn’t answer before:

$ aai-contact-find --phone "3105550100"

CONTACT LOOKUP — "3105550100" (detected as phone)
─────────────────────────────────────────────────

Phase 2 — /caseFiles/view deep walk (1 case of 219 scanned, 2 field hits):
  Case #[CASE-#]    Sample, Alex
    applicant       310-555-0100   (Applicant.Phone[0].digits)
    display-rollup  310-555-0100   (CaseDetailsDisplayParty.Phone[0].digits)

One case. Two field paths reported. Each match tagged with a role inferred from the path prefix — applicant when the path starts with Applicant., employer for Employer., witness for Witness., display-rollup for the API’s generated rollup paths. The role tells the attorney why the case surfaced, not just that it surfaced. A phone tagged applicant means the client. A phone tagged carrier means a claims adjuster.

The deduplication step keeps each (role, value) pair once per case. So the same phone appearing on the deep record and on the display rollup reports as one applicant entry plus one rollup entry, not two indistinguishable hits.

The decision not to cache

A deep walk that takes 30 to 60 seconds per invocation is exactly the kind of thing where a cache layer feels obvious. Fetch all the case views, build an inverted index of (normalized value → cases), write it to disk with a five-minute TTL. Second lookup is instant.

This skill does not have a cache. The decision is deliberate.

The case where freshness matters most is the case where the cache fails the attorney. An assistant just updated an applicant’s phone number; the attorney runs the lookup ninety seconds later and gets a cached answer that doesn’t include the new value. Or worse, the assistant deleted a witness contact because it was a duplicate; the cache still shows the witness on the case, and the attorney calls a number that was supposed to be retired. A stale cache is correct most of the time and wrong at the moments where wrong is most expensive.

The thirty-to-sixty-second cost is operator-visible. Progress prints to stderr line by line as the batches complete. The attorney sees the work happening; the work returns fresh data. Two lookups in a row cost two full walks. That’s honest. A cached answer that’s sometimes wrong would be cheaper and worse.

What this does not search

The deep walk covers structured fields in /caseFiles/view. It does not cover:

A phone number mentioned only in a deposition transcript or an attorney’s notes will not surface. For content-level matches, fetch the specific endpoint and grep client-side. A future iteration may add an opt-in flag that sweeps activity descriptions and upload metadata, but the current pass is structured-field-only. Content-level matches get noisy fast — phone numbers appear in transcripts and witness contact dumps everywhere — and the right place to introduce that noise is opt-in, not default.

What was verified before this shipped

The skill passes the existing codified checks for syntax, prose-references, no-firm-data, and cache-path-canonical (the last because the skill deliberately does not maintain a cache, so it has no cache path to drift). The bin was live-tested against the originating query: the phone the flat-index skill could not find resolves to its case in about forty seconds, with both the deep-record path and the display-rollup path reported, role-tagged, and deduplicated.

Name searches against the flat index still work the same way they did before. The new bin runs the same NFD-decompose normalizer the prior find-contact skill used and the same normalizer the binding library and case-search bin use, so name matching is identical across all three surfaces. The old find-contact skill prose now routes through the new bin instead of inline bash, so the name-search subset inherits the deep-walk fallback automatically: a name that appears only on a deep field (in a witness contact name, say, that isn’t at the top of the index) also surfaces.

What this reveals about API design more broadly

The index-versus-view split is a routine REST tradeoff. The lesson from this particular gap isn’t “the upstream API is wrong.” The slim index is the right design for the listing pages it was built for; the gap appears only when the search behavior the attorney expects doesn’t match the field set the index actually carries.

The lesson is: if a search skill is going to support a query type, the skill has to be willing to walk wherever that data actually lives. Promising phone search and searching only the flat index is the kind of failure where the skill returns zero matches with full confidence — the worst failure mode, because the attorney doesn’t know whether to trust the answer.

The phone-lookup gap exists in every system that has a fast index endpoint and a deep view endpoint and indexes only the fields useful for the listing page. The address-lookup gap, the email-lookup gap, the witness-contact-lookup gap all have the same shape. The deep walk fixes all of them at once — phone, email, address, name, anything that lives in a string field anywhere in the case file view tree. The cost of the walk amortizes across every query type that depends on it.

The thirty-second walk is a small price to pay for a search skill that can’t tell the attorney with confidence that a phone number is not in the system when it actually is.