The last two releases fixed silent-failure patterns in the shell layer. v4.5.117 made parallel-fetch skills degrade gracefully and tell the operator which endpoint failed. v4.5.118 added pipefail to single-fetch pipelines so failed API calls couldn't be swallowed by downstream consumers. Each release closed one face of the same bug shape: shell-level silent failures.

This release closes a third face — at the binary level, inside the binding pipeline.

The pattern

Both aai-bind-message.mjs and aai-bind.mjs fetch an activity-type catalog from the firm's Merus instance to give the classifier model a list of valid tags. They also fetch the firm's user directory so the classifier can distinguish internal email senders from external ones. These two fetches are auxiliary to the bind — the bind can technically complete without them.

The original code expressed that with a graceful-degrade fallback:

function fetchActivityTypeCatalog() {
  if (_cache) return _cache;
  const r = spawnSync('node', [MERUS_FETCH, '/activityTypes/index'], {...});
  if (r.status !== 0) {
    console.error('warning: could not fetch activity-type catalog');
    _cache = [];
    return [];
  }
  let parsed;
  try { parsed = JSON.parse(r.stdout); } catch { return []; }
  // ... process ...
}

The intent was right — don't crash the bind if the auxiliary call fails. But the implementation had two subtle bugs:

So three out of three failure paths (spawn-fail, parse-fail, empty-result) ended in the same place: an empty array, used as a "catalog unavailable" signal to the prompt. Two of those three paths fired no warning at all.

Why empty catalog is worse than no bind

The prompt template handles an empty catalog gracefully: it tells the LLM "catalog unavailable — return empty suggested_activity_type_ids" and the bind completes. From the LLM's perspective, the bind succeeded. From the system's perspective, an activity record got created and linked to a case.

The activity just has no tags.

This system uses tags heavily. Tags drive:

An activity with no tags is invisible to most of these flows. It still exists, still shows up in chronological feeds, but doesn't surface anywhere the system would normally show it. In a busy firm, untagged activities silently accumulate, and nobody notices until weeks later when an attorney looks for a QME report that the system "doesn't have" — except it does, just untagged.

That's the worst-case outcome of a silent fallback in a write pipeline. Data quality silently degrades, and the operator has no idea their API token's permissions need attention.

The fix

Every failure path now routes through one of two helpers:

function emptyCatalogFallback(reason) {
  console.error('[aai-bind] WARNING: activity-type catalog unavailable (' + reason +
    '). Binding will proceed with NO tag suggestions — the resulting ' +
    'activity will be filed UNTAGGED. Check the API token\'s ' +
    '/activityTypes/index permission.');
  _cache = [];
  return [];
}

The reason string is specific to the failure mode: merus-fetch exit 1: API error: insufficient privileges for the spawn-fail path, non-JSON response: <html>...</html> for the parse-fail path, parsed response yielded zero usable entries for the empty-result path.

The warning gets emitted exactly once per process (the cache stores the empty result, so subsequent calls hit the cache and don't re-spawn). The operator sees a single loud message identifying which permission needs to be granted and which downstream consequence to expect.

One more discipline bug fixed

The pre-fix code had a subtle caching bug: the spawn-fail path cached [] and exited, but the parse-fail path returned [] without caching. So if the first call failed at parse-time, every subsequent call within the same process would re-spawn the failing API call. Across a batch bind run of 50 messages, that was 50 wasted API roundtrips with 50 identical warnings.

The new helper centralizes the cache assignment. All three failure paths now cache the empty result so the warning fires once per process, not once per call.

Where this leaves the silent-failure sweep

Three releases now form the full sweep:

The unifying lesson: any code path that "gracefully" returns empty on failure is a candidate for silent data degradation if the empty value flows into a downstream write. Graceful degradation is only graceful if the operator knows it happened. Without the warning, it's just silent data loss.