The shape of the bug: a function takes an entry object from the caller, builds a stored record by reading specific fields off it, and writes the record. Any field the caller included that the function didn't read is silently absent from the stored record. The function returns "success" because nothing failed. The caller has no way to know that half of what they thought they stored isn't there.

export function setBinding(uploadId, entry) {
  if (!entry || !entry.applicant_last) throw new Error(...);
  return withFileLock(BINDING_LOCK, () => {
    const all = readBindings();
    all[String(uploadId)] = {
      upload_id: String(uploadId),
      applicant_last: String(entry.applicant_last).toUpperCase().trim(),
      applicant_first: entry.applicant_first ? ... : null,
      applicant_sources: Array.isArray(entry.applicant_sources) ? entry.applicant_sources : [],
      // ... 11 more named fields ...
    };
    writeBindings(all);
    return all[String(uploadId)];
  });
}

The function persists 14 specific fields. If the caller passes case_file_id: 123, that field never gets read and never gets stored. The returned object has no case_file_id. A subsequent getBinding(uploadId).case_file_id returns undefined. The lock held during the write was held over the right operation — just the operation didn't include the field the caller thought it did.

Why this matters more than the usual API contract bug

This particular storage layer is the binding guard. Its job is to record what applicant a document belongs to before any write to the case-management API attaches the document to a case. The guard's role in the larger system is to prevent the worst possible failure mode of a legal-tech tool: uploading an applicant's medical records to a different applicant's case file.

If a future caller assumed they could write extra fields through the entry object — say, a code path that wanted to record the case_file_id as part of the binding for later cross-checks — they would get back a binding with that field missing, no error, and no warning. The cross-check would compare undefined to a real case ID and either always-pass or always-fail depending on which way it was coded. Neither is a guard.

The current single caller (the aai-bind tool) only passes the 14 in-schema fields, so this bug hasn't bitten anyone yet. The probe found it before a second caller could be added.

The fix

Make the schema explicit and fail loud on anything outside it:

const SET_BINDING_FIELDS = new Set([
  'applicant_last', 'applicant_first', 'applicant_sources', 'applicant_confidence',
  'failed_extraction', 'claim_number', 'doc_date', 'doc_type',
  'suggested_activity_type_ids', 'suggested_activity_type_label',
  'suggested_activity_type_certainty', 'pdf_sha256', 'extracted_at', 'extractor',
]);

export function setBinding(uploadId, entry) {
  // ...
  const unknown = Object.keys(entry).filter(k => !SET_BINDING_FIELDS.has(k));
  if (unknown.length > 0) {
    throw new Error(`setBinding: unknown field(s) in entry: ${unknown.join(', ')}.`);
  }
  // ... existing write path ...
}

Now a caller that passes a typo or an unsupported field gets a typed error naming the rejected fields and listing the allowed ones. The contract becomes verifiable at the call site.

Same fix in the sibling message-binding module. Both real call sites verified to use only allowlisted fields — no caller breakage.

The phantom-key bug

The same probe round found a second bug in the same modules. The storage layer uses the upload ID as the dictionary key. The key was computed as String(uploadId), with no normalization. A caller that passed ' 123 ' (with surrounding whitespace, which is easy to do when reading the ID from a URL param, a log line, or any user-typed source) would store the binding under the key ' 123 '. A subsequent getBinding('123') (the obvious form) wouldn't find it.

The binding existed. It was just unreachable through the form anyone would naturally use.

Fix is a small canonical-key helper:

function canonicalId(id) {
  if (id === null || id === undefined) return null;
  const s = String(id).trim();
  return s === '' ? null : s;
}

Applied to store, read, and clear paths. After the fix, all four forms — string-with-whitespace, string-without-whitespace, number, and number-as-string — resolve to the same record. setBinding(123) and getBinding('123') and getBinding(' 123 ') all work.

What the probe found that wasn't a bug

Nine edge cases probed; two surfaced bugs. The other seven confirmed prior fixes still hold or were behaving as designed:

The "found 2 of 9" ratio is roughly what these probe rounds produce — most boundary conditions are already handled (the file lock, the PID liveness check, the SHA-256 verification on the PDF, the lock-acquisition clock-skew guards added in 4.5.87), and the bugs that remain are in the parts that don't have explicit boundary-case tests.

The general lesson

Any function that takes an object parameter and builds a stored representation field-by-field has an implicit schema. The schema is the set of field names the function reads. Anything not in that set is silently discarded. For storage layers where contract violations have downstream safety implications, the schema should be explicit, the unknown-key case should fail loud, and the read paths should normalize whatever form of key the caller passed.

The version on the install URL is 4.5.92.