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:
- The parse-failure path was silent. If the spawn succeeded but returned non-JSON (e.g., a connectivity-error HTML page from a proxy), the catch swallowed the error and returned an empty array. No warning, no log entry, no signal to the operator.
- The empty-result path was silent. If the spawn and parse both succeeded but the records array ended up empty (e.g., a Merus instance where the catalog endpoint returned a structurally-valid but data-empty response), no warning fired.
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:
- Case organization (filter activities by "QME/AME Reports" tag in the case view).
- Mail processing rules (incoming letters tagged as "Ins. Correspondence" route differently from "DA NOR").
- Report filters (find all UR/IMR denials by tag 47933).
- Health-check logic (recent QME reports trigger 30-day objection deadline calculations).
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:
- v4.5.117 — shell-layer parallel-fetch graceful degrade.
- v4.5.118 — shell-layer single-fetch pipefail.
- v4.5.119 — binary-layer auxiliary-fetch loud warnings.
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.