The bind tool in this codebase has one job: read a PDF, extract the applicant identity from it via the LLM, and store a binding record that the downstream guard layer checks at write time. The binding contains the extracted applicant name, the applicant confidence level, the document type, and a SHA-256 hash of the PDF bytes. The hash is there so that if anything swaps the PDF between extract and file — a manual edit, a download that re-fetched a different revision, a race in some other tool — the guard refuses the write.

The probe-round finding: the hash was almost never being captured. Bindings stored with pdf_sha256: null, the guard's hash check fell through to its backwards-compatible no-hash path, and the tamper detection was silently inert for the common code path.

The cause was a six-line ordering bug. Here is the original code:

let extracted;
try {
  extracted = extractWithClaude(pdfPath);
} finally {
  if (cleanupTmp) {
    rmSync(cleanupTmp, { recursive: true, force: true });
  }
}

// Hash the PDF for tamper detection in future writes
let hash = null;
if (existsSync(pdfPath)) {
  try { hash = sha256(pdfPath); } catch {}
}

Read it carefully. The try/finally extracts the binding via Claude, then unconditionally cleans up the temp directory if the tool downloaded the PDF itself (i.e., the caller didn't pass an explicit PDF_PATH). The cleanup deletes the entire temp dir, including the PDF. Only then does the code check whether the PDF exists and compute the hash.

For the explicit-PDF-PATH code path, cleanupTmp is null, the finally block doesn't run, the PDF still exists, and the hash is computed correctly. For the downloaded-PDF code path — which is what the agent invokes when processing new mail, which is the path that fires constantly in production — cleanupTmp is set, the finally block deletes the file, existsSync(pdfPath) returns false, and the hash stays null.

The binding store proceeds with pdf_sha256: null. The downstream guard's hash check, intended to catch PDF swaps:

if (!binding || !binding.pdf_sha256) {
  return { ok: true, warning: 'no_bound_hash' };
}

treats no-hash bindings as OK and returns without comparing anything. The comment in the source explained the intent: backwards compatibility with bindings created before SHA tracking was added. That was a deliberate choice for legacy data. It also covered every binding the downloaded-PDF path had ever created — which was the vast majority of them. The compatibility fallback became the universal path, and the actual tamper-check ran only for the corner case.

Why this didn't show up in tests

The tests for aai-bind all use the explicit-PDF-PATH form. The caller passes a known PDF on disk, the tool extracts, the binding is stored with a hash, the tamper guard test then swaps the PDF and confirms the guard catches it. Green.

The downloaded-PDF code path is exercised in production — the mail-processing agent calls aai-bind with just an upload ID, the tool downloads the PDF into a temp dir, extracts, cleans up. The tests didn't cover this path because spinning up a real Merus download in a test is heavy and the tested path (explicit PDF) was assumed equivalent.

The two paths are NOT equivalent in their tamper-detection behavior. The test-covered path captures the hash. The production path doesn't. The bug was probably present from the time the tmp-dir download was added — I'd guess months ago, given how settled the surrounding code is.

The fix

Move the hash computation inside the try block, before the cleanup runs:

let extracted;
let hash = null;
try {
  extracted = extractWithClaude(pdfPath);
  try { hash = sha256(pdfPath); } catch {}
} finally {
  if (cleanupTmp) {
    rmSync(cleanupTmp, { recursive: true, force: true });
  }
}

The PDF is still in the temp directory at the moment the hash runs. The cleanup runs afterward and removes the temp dir. Both code paths (explicit and downloaded) now capture the hash consistently.

The broader pattern: cleanup vs. read ordering

This is an instance of a general resource-lifetime bug. Any time a try/finally cleans up a resource that's needed by code downstream of the try, the cleanup runs before the use. The compiler is happy because pdfPath is still in scope; existsSync is happy because it returns false rather than throwing; the rest of the code is happy because it has a fallback path for "no hash available." Every individual piece is correct in isolation, and the integration is wrong.

The signal to look for: a try/finally where the finally block removes something the code below the try is going to need. The fix is always the same — move the dependent operation inside the try, before the finally fires.

In this case the fix moves three lines of code. The change is tiny. The bug it closes was running on every production binding for an unknown number of months. The asymmetry between "easy to fix" and "expensive to leave broken" is the thing that makes resource-ordering bugs frustrating: they look trivial in retrospect, and they sit there indefinitely because nothing about the production behavior says "this is broken."

Defense-in-depth fix in the same release

While I was in the file, I added a defensive typeof check on the extracted applicant_last. The Claude SDK call declares the schema with applicant_last: { type: 'string' }, and the SDK normally enforces it. If the SDK ever weakly enforces (version skew, future API change, edge case in the structured-output parsing), an array or nested object could land in that field. The downstream code does String(extracted.applicant_last).toUpperCase().trim(), which coerces {first:"John",last:"Smith"} to "[object Object]" — same family as the HMAC-canonicalization collision from earlier this month.

The defensive check fails loud at extract time with a typed error message naming the actual shape. Even if the SDK ships a bug that lets a non-string through, the binding never gets stored with a malformed key.

The version on the install URL is 4.5.103.