A tenure case at Cornell reaches the review committee as a single PDF of dozens of letters, evaluations, syllabi, and statements, each routed to its own numbered slot in a hierarchy the committee spells out exactly. Chasing down the contents takes months, and no software shortens it. Assembling the PDF is hours of downloading, classifying, and merging through menu after menu, per section, per dossier, per candidate, and a revision starts the whole pass over.
The brief was a web app behind the university's sign-on where a staff member picks a candidate and gets a finished, bookmarked PDF on the other side.
The documents live in Interfolio, the faculty-information system, and its API authenticates with HMAC-SHA1 request signing: assemble a canonical string from the verb, two blank lines, the timestamp, and the path, sign it, wrap it in their envelope. The format is strict and the failure mode is a polite 401 that does not say why. A neighboring college had a Python desktop tool that already spoke it, so I read theirs and ported the signing flow into Node1:
function generateHMACHeader(privateKey, publicKey, timestamp, requestString, requestVerb) {
const verbRequestString = `${requestVerb}\n\n\n${timestamp}\n${requestString}`
const signedHash = crypto.createHmac('sha1', privateKey).update(verbRequestString, 'utf8')
return `INTF ${publicKey}:${signedHash.digest().toString('base64')}`
}
Signed requests get the files out. Then every file needs its slot. The committee's spec is a numbered hierarchy; what comes back from Interfolio is a pile of PDFs with titles people typed, in counts that change from case to case. So a template is the spec written down as data. Each slot carries a pattern, matched case-insensitively against everything Interfolio says about a file: section, title, type, filename. Patterns are forgiving on purpose; Submit.*Vote.*Justification catches the committee voting form however it was titled. And a slot keyed with a trailing .+ takes however many files match it, one numbered subsection each:
"5.3.+": {
"title": "Letters from External Reviewers Selected by the Department",
"parent": "5.3",
"dynamicSection": true,
"extractLastName": true,
"files": [{ "source": "interfolio", "match": "Letters from External Reviewers Selected by Department", "sort": true }]
}
Letters arrive as "Letter from First Last", "Last, First.pdf", or "Evaluation from First Last", and a bookmark should read as a name, so the builder pulls the last name out of whichever format shows up and titles and sorts the subsections with it. That keeps the spec in the hands of the people who own the process. When the committee reorders the hierarchy or a department rewords a section, they edit the template and the next build picks it up. I don't have to be involved.
Matching the spec was the entire requirement, so I was free to pick the best tool for each piece. The Node PDF libraries I tried could bookmark or merge, not both. Python's pypdf does both, and the neighboring college's Python was already on offer, so the PDF build stayed Python and I refactored their code into the builder. The Node API validates the packet and queues a build on Redis; a worker picks it up, downloads each file with the same signed headers, matches the files against the template, and hands the populated template to the Python builder, which writes one merged PDF with the committee's hierarchy as its bookmarks. The queue also moves the PDF work off Node's single thread, so if a review season ever lands several packets at once they build in parallel instead of in line. Click to finished packet runs about five minutes, dominated by downloads for packets in the thirty-to-sixty-file range.
The builder
Redrawn from the live tool with sample names. The list is the whole interface.
Opening a candidate is the only other screen. It shows what the system already knows (packet type, unit, candidate) and asks for the only two documents that live outside it, which a staff member can attach or skip. Skipping builds a preview; attaching builds the real thing.
The build modal
The preview path exists because staff wanted to see packets before the uploads were final.
The test rollout before launch, with the two staff members who own tenure packets, surfaced the bugs. Builds occasionally reported failure after succeeding, double-clicks queued duplicate jobs, and one template put files under the wrong bookmark because a variable-count section and a fixed one collided over the same number. By launch in late November 2025 the two of them had generated every current template themselves, and the first weeks live ran without an issue.
-
The signing scheme (verb, two empty lines, timestamp, path, HMAC-SHA1, base64, an
INTF publicKey:digestenvelope) matches the public reference implementation maintained by the University of Pennsylvania: github.com/sas-irad/interfolio-api. ↩