The email-classification tool in this codebase takes a message ID, looks up the message in the upstream system, asks Claude to classify it (sender role, direction, suggested activity tags, optional applicant hint), and stores the result as a binding that the downstream guard layer consults when filing activities.
The Claude call is bounded by a JSON schema. sender_role and direction are declared as required strings. direction is constrained to the enum ['received', 'sent']. The other fields are nullable strings or arrays. The SDK is supposed to enforce the schema and reject responses that don't match.
The probe round didn't find a bug in the SDK. It found a bug in what happens IF the SDK ever weakly enforces — which is the entire point of defensive programming at trust boundaries.
The setup
The extract function looks like this:
const r = spawnSync(claude, args, ...);
let payload = JSON.parse(r.stdout);
let extracted = payload.structured || null;
if (!extracted && typeof payload.result === 'string') {
try { extracted = JSON.parse(payload.result); } catch {}
}
if (!extracted && payload.result && typeof payload.result === 'object') {
extracted = payload.result;
}
if (!extracted) die(`could not parse structured output`);
return extracted;
The only check at the end: extracted is truthy. The function returns. The caller then writes the binding:
const stored = setMessageBinding(messageId, {
sender_role: extracted.sender_role,
direction: extracted.direction,
suggested_activity_type_ids: extracted.suggested_activity_type_ids || [],
suggested_activity_type_label: extracted.suggested_activity_type_label,
// ...
});
And inside setMessageBinding:
all[key] = {
sender_role: entry.sender_role || 'unknown',
direction: entry.direction || 'received',
// ...
};
Three layers, each correct in isolation:
- The SDK call returns whatever structured output Claude produced.
- The caller passes the fields through.
- The storage layer applies defaults to handle missing fields gracefully.
Composed, they produce silent contract violations. If Claude's response is missing sender_role, the extract function returns the partial object (truthy), the caller passes sender_role: undefined to setMessageBinding, and the storage layer substitutes 'unknown'. The binding is stored. The caller's {ok: true, binding: stored} output looks identical to a successful classification.
The downstream guard then consults the binding. It sees sender_role: 'unknown' and proceeds with whatever default behavior corresponds to "unknown sender." The actual classification — which Claude failed to produce — never enters the system. The attorney sees activity tags applied with confidence that the model didn't actually have.
Why defaults belong at one layer, not many
The mistake here is that "soft defaults" appear in the storage layer. That's the wrong layer for the default to live, for the same reason that a database doesn't usually substitute defaults for NULL on read — it would mask data-quality issues upstream.
For data that flows from an extract step into a storage step, there are two clean models:
Model A: Required fields are required everywhere. The extract layer dies if they're missing. The storage layer assumes they're present. No defaults anywhere. Clean and noisy.
Model B: Required fields are explicitly optional with documented defaults. The storage layer defines the default. The extract layer is allowed to omit them. Soft and quiet.
The bug is the hybrid: the SCHEMA says required, the storage layer applies defaults. The extract layer can produce schema-violating output that the storage layer silently fixes. Now the contract has two contradicting sources of truth — the schema and the storage defaults — and the storage defaults win.
The fix in this release is to commit to Model A. Required fields are required. The extract layer validates them after the SDK call and dies if any are missing or have the wrong type. The storage layer keeps its defaults for genuinely-optional fields, but a missing required field never reaches storage.
The other half of the round
The same probe found a related shape in the upstream fetch. The function loaded the messages index and looked up the target message by ID:
const data = parsed?.data || parsed || {};
const entry = data[String(messageId)] || data[Number(messageId)];
The fallback chain handles two known response shapes — wrapped ({data: {...}}) and unwrapped — and falls back to empty object if neither. The fallback handles three of the four possible shapes. The fourth shape — a top-level ARRAY — is what produces the bug.
The upstream system has been observed to return a top-level array on some endpoints under error conditions. When that happens, parsed?.data is undefined, the fallback takes parsed directly (the array), and the keyed lookup data[String(messageId)] becomes array-index access. For small message IDs like "5", that returns the array element at index 5 — the wrong message. The classification then proceeds on the wrong record.
The fix is to validate the shape before key lookup. If the parsed body or its entries store isn't a non-array object, die with a typed shape error. Same pattern as the null-body guards we've added across this codebase.
The general lesson
Trust boundaries are where validation lives. An LLM call is a trust boundary. An upstream API call is a trust boundary. The instinct to be lenient — handle the SDK returning slightly different shapes, handle the API returning unwrapped responses, handle missing fields with defaults — is well-meaning and produces silent failures.
The discipline is the opposite: fail loud at the trust boundary. If the LLM didn't produce what the schema requires, that's an extraction failure. If the API returned the wrong shape, that's a fetch failure. If the storage layer needs a default, the default should be at the OUTERMOST boundary that has enough context to substitute it sanely — usually the calling tool, not the storage layer. Defaults applied without context turn extraction failures into invisible bad data.
The version on the install URL is 4.5.106.