Submission packet generation
How we configure the documents, order, format, and submission channel each client requires.
On this page
A clinician taps the apply button on a job in our marketplace. On our side, a small clock starts. Within a few minutes, a packet has to land in the client’s vendor management system: the right documents, in the right order, in the right shapes, submitted through the right channel. Get any of those wrong and the VMS rejects the submission, the clinician’s spot in the queue evaporates, and someone on our team gets a Slack ping that opens with the word “hey.”
A submission packet contains a cover sheet, resume, references, skills checklist, license verifications, certifications, health documents, proof of ID, and sometimes client-specific forms or a radius statement. Each client has requirements for which documents to include, their format, and their order.
For most of Trusted’s history the way we handled that variation was code. A new client onboarded, an engineer wrote some packet-assembly logic, the logic mostly worked, and a handful of edge cases settled into the codebase like sediment. When the next client onboarded, we wrote more.
Every client has different packet requirements
Generating and combining PDFs is straightforward compared with managing the variations between clients.
One client wants the cover sheet first, the resume second, references next, then the skills checklist, then licenses, then certifications. Another wants the resume first because their VMS displays the first attachment in a preview pane. A third doesn’t want references included at all because their compliance team collects them out of band. A fourth wants a custom form embedded between the resume and the references. A fifth wants the packet uploaded by a human; a sixth has an API we can submit to; a seventh wants us to generate the packet but hand it to a person at the client for the final upload.
Cross-multiply that against several VMS platforms, hundreds of programs, and tens of thousands of jobs, and the failure mode of code-as-configuration becomes obvious. Every change is a deploy. Every new client is a code review. Every edge case is a conditional in a method that no one wants to be the last person to touch.
After too many late-night packet fixes, we moved those variations into structured rules with defaults and overrides.
Three rule models, one job
Submission rules live in the database at the job, program, or global level. The system resolves them when it assembles the packet.
Three rule models control different parts of the packet.
SubmissionPacketFormat owns order. It defines where each item type sits in the assembled packet: cover sheet at position N, resume at position M, and so on across every item type we know how to assemble. The model validates that no two items share the same order position, because a packet with two items both claiming slot three is, by definition, not an ordered packet.
SubmissionPacketInclusion owns membership. It defines which item types are required for this program and which can be skipped: a flag per item type, evaluated when the packet is assembled. Required certifications, but not health docs. Required references, but not a radius statement. The inclusion rule is the gate; the format rule is the order on the items that make it through the gate.
SubmissionPacketApi controls the submission channel and which steps can run automatically:
allow_manual_submission
allow_automatic_submission
allow_packet_auto_generation
allow_nursing_ai_qualification
allow_auto_withdraw
That short list does more than it looks like. The first two control whether the packet goes out via a human or via an API. The third decides whether we assemble the packet without a human at all or just stage it for review. The fourth gates whether our AI qualification system is allowed to act on this job’s pipeline. The fifth handles the exit case: whether the system can withdraw a submission on its own when the clinician’s circumstances change. Five flags, five orthogonal product decisions, all expressible as configuration on the program record. Every one of them used to require a code change to answer differently per client. Now it’s a row update.
Here’s a simplified format rule. Its items hash lists the document types, and a validation prevents duplicate positions:
class SubmissionPacketFormat < ApplicationRecord
SUBMISSION_ITEMS = {
cover_sheet: :cover_sheet_order,
resume: :resume_order,
references: :references_order,
skills_checklist: :skills_checklist_order,
license_verifications: :license_verifications_order,
certifications: :certifications_order,
health_documents: :health_documents_order,
proof_of_id: :proof_of_id_order,
client_form: :client_form_order,
radius_statement: :radius_statement_order
}.freeze
validate :submission_item_orders_unique
def ordered_items
SUBMISSION_ITEMS
.filter_map { |item, attr| [item, public_send(attr)] if public_send(attr).present? }
.sort_by(&:last)
.map(&:first)
end
private
def submission_item_orders_unique
orders = SUBMISSION_ITEMS.values.map { |attr| public_send(attr) }.compact
return if orders.uniq.size == orders.size
errors.add(:base, "submission item orders must be unique")
end
end
The hash is the schema. The validation is the contract. The ordered_items method is the entire assembly surface that callers depend on. A new item type is one entry in the hash, one column on the model, one validation pass. When a new client wants a different order, somebody edits the rule row and the next packet goes out in the new order. No deploy.
Job over program over global
The rules above don’t exist in a single place. They’re defined at three levels: a global default, a per-program override, and a per-job override. At packet-assembly time, the system walks up the hierarchy and uses the most specific rule that exists.
If the job has a SubmissionPacketFormat row, that’s the format. If not, the program does. If neither has one, we fall back to the global default, which exists exactly so the resolution always terminates. Same for SubmissionPacketInclusion. Same for SubmissionPacketApi. The resolver is small enough to fit on one screen and reads as: try job, try program, return default.
Most jobs use their program’s defaults. A job-level override handles exceptions, such as one ICU posting with a different packet requirement, without changing the rules for the rest of the program.
Onboarding a new client becomes a different kind of work, too. A new client lives at the program level: we write program rules for that client, and the existing job-level overrides for any quirky postings still apply on top. No code change. No deploy. The same SubmissionPacketFormat model that’s been in production for years runs the new client’s packets, because the model is the schema and the schema doesn’t care which client’s data is filling it in.
What assembly actually does
With the rules resolved, assembly follows the configured document list.
Given the resolved rules for a job, assembly walks the ordered list from SubmissionPacketFormat#ordered_items, filters it against SubmissionPacketInclusion, and for each surviving item type pulls the clinician’s actual artifact: their generated resume PDF, their completed reference PDFs, their Nursys-issued license verifications, their pre-filled client form. The artifacts get concatenated in the order the format rule specified, the resulting packet gets validated against the inclusion rule one more time, and the packet is ready to submit.
The submission step is the only one where the API rule does work. If allow_automatic_submission is true for this job’s program, the packet goes out via our VMS integration directly. If only allow_packet_auto_generation is true, the system assembles and stages the packet for a human to upload. If only allow_manual_submission is true, we don’t even assemble; the packet stays a checklist that a human works through. Three product modes, one assembly pipeline, controlled by configuration rather than by which method gets called.
We deliberately kept the assembly logic dumb. Each item type knows how to render itself. Validation runs at the same gates a human curator would hit if they uploaded the packet by hand, on purpose---the same posture we wrote about in Agents that fill out forms. When the validation rejects a packet, the rejection is shaped the same way whether the assembly was done by code, by an agent, or by a person. One validation surface, three actors.
When the packet is wrong, the question is which rule
A submission gets rejected. Something didn’t make it in, or made it in wrong, or made it in out of order. The first question is which of the three axes failed.
Because the configuration is data and the data is versioned, the answer is in the database. The state machine that records every transition a submission goes through writes to the same audit surface we cover in Catching every write, so we can reconstruct which rules were in effect at the moment the packet was assembled, which override resolved, what got included, what didn’t. The investigation is mechanical. Pull the rules at the time, pull the assembled packet, diff against what the VMS expected. Most rejections narrow to a single rule row that was misconfigured, almost always at the program level, almost always fixable by editing the row.
Previously, investigating a rejected packet meant finding the client-specific branch in the assembly code. Now we can inspect the versioned rule that produced it.