Submission packet generation

Aug 14, 2024 · 8 min
marketplace

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.”

The thing that lands in the VMS looks, from a distance, like a form. It isn’t. A submission packet is an assembled document set: cover sheet, resume, references, skills checklist, license verifications, certifications, health documents, proof of ID, sometimes a client-specific form, sometimes a radius statement. The order of those items matters. Whether each one is included matters. The format matters. And every client wants it slightly differently.

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.

The packet is a configuration problem in document clothing

The naive framing is document generation: pull credentials, generate PDFs, concatenate, submit. What makes it hard isn’t the assembly. It’s the variation.

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 one too many late-night packet patches, the pattern we converged on is the one we’ll keep using---encode the variation as structured data, resolve it hierarchically, and keep the code small.

Packet assembly pipeline laid out in three columns. On the left, three configuration inputs labeled Format rules, Inclusion rules, and API rules feed into a center column showing an ordered packet with six numbered slots: cover_sheet, resume, references, skills_checklist, license_verifications, certifications. From there arrows fan out to three output modes on the right: Manual, Auto-generated, and (in mint) Auto-submitted.

Three rule models, one job

The configuration of a submission packet at Trusted is stored as job rules. Not application code, not feature flags, not a YAML file in a deploy repo. Rules in the database, attached to the job, the program, or the global default, and resolved at submission time.

Three rule models do the work, and each one owns a single axis of the variation.

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 owns the channel and the automation level. It’s a set of booleans that read, taken together, like an architectural manifesto:

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 the shape of the format rule, lightly cleaned. The detail that earns its keep is the items hash and the validation that enforces uniqueness of order across them:

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.

Two-column figure. On the left, three nested horizontal layers from wide (bottom) to narrow (top): GLOBAL DEFAULT, PROGRAM RULES, JOB RULES, with a mint OVERRIDE arrow running down the right side showing more specific layers override less specific ones. On the right, an explainer block titled First match wins shows the resolver formula: job ?? program ?? default.

The hierarchy matters more than it looks like. Most jobs are happy with their program’s defaults, which are themselves happy with the global default for most fields. The pattern lets us configure the long tail of jobs that aren’t (the one ICU posting at one facility that has a quirky packet requirement) without polluting the program rule or, worse, the global default. The override sits on the row that needs it. It doesn’t leak.

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

Assembly is the boring part of the system, and we mean that as a compliment.

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.

When we treated packet variation as code, the equivalent investigation involved someone reading a method and remembering which branch had which client’s name in a comment. The audit isn’t a feature we added; it’s the natural shape of a system whose configuration is data.

--- Engineering

← back to posts