Skip to content

Salary Band Import Runbook

Use this runbook to turn published federal wage percentiles into a starting salary ladder for a discipline, using scripts/fetch-salary-bands.js.

This script writes nothing. It reads a tenant's headquarters location and its disciplines' occupation mappings, calls the CareerOneStop wage API, and prints the resulting ladder boundaries for a human to review and paste in — no database write, no endpoint call, no audit message. There is no "apply" flag.


Prerequisites

  1. The tenant's HQ location must be set. In /admin, find the tenant row and set HQ Location (e.g. Ann Arbor, MI). See the tenant-shape section of CLAUDE.md. If it isn't set, pass --location="City, ST" on the command line instead.

  2. At least one discipline must have an occupation code mapped. On /console/disciplines, open a discipline's Edit modal and pick its occupation from the grouped picker. Disciplines with no mapping are skipped and listed — never guessed at.

  3. CareerOneStop API credentials. Register for free at https://www.careeronestop.org/Developers/WebAPI/registration.aspx, then set:

    • CAREERONESTOP_USER_ID
    • CAREERONESTOP_TOKEN

    Do not commit these anywhere, and do not add them to a tenant file — they are process environment variables only.


Running it

node scripts/fetch-salary-bands.js --tenant=<id> [--location="Ann Arbor, MI"] [--dry-run]
  • --tenant=<id> — required, matches the tenants file.
  • --location=<..> — optional; overrides the tenant's stored hq_location for this run only.
  • --dry-run — lists which disciplines would be queried and with what location, without making any network calls or requiring API credentials. Use this to sanity-check the setup before spending an API call.

Reading the output

For each mapped discipline, the script prints:

  • The occupation title and code it queried.
  • Which geography tier the numbers came frommetro/local, state, or national. A ladder built on national numbers for a company in a small town is a legitimate answer, but you need to know that's what you're looking at before you paste it in.
  • The four raw wage anchors (10th, 25th, 75th, 90th percentile annual wage).
  • The five resulting ladder boundaries, formatted for pasting straight into the salary ladder modal on /console/benchmarks (floor, the three interior boundaries, ceiling).

At the end, a summary line: how many disciplines produced a ladder, how many were skipped or failed, and how many had no occupation mapped at all.

The published figures are a starting point, not a verdict. Some businesses deliberately pay above the local market to win talent; some pay below it and compete on other things. Treat the printed ladder as a first draft to sanity-check against your own pay strategy, not a number to paste in unquestioned.


Why some disciplines don't get a ladder

The script refuses rather than approximates. You'll see one of these reasons:

  • "no occupation mapped" — the discipline has no soc_code set. Set one on /console/disciplines and re-run.
  • "top-coded/censored value" — published wage data caps very high earners at a bound (e.g. "≥ $239,200") rather than a real figure. Arithmetic on a capped value is meaningless, so the script refuses that geography tier and tries the next broader one; if every tier is capped, the discipline is reported unavailable.
  • "no data published for this geography" — the tier is simply empty for that occupation × location (a suppressed cell). The script falls through to the next broader tier automatically.
  • A data-anomaly refusal (e.g. two percentiles came back equal) — the published figures for that geography don't support a valid ladder. This is reported directly; it does not fall through to a different tier, since the data existed but didn't make sense.
  • An HTTP or network failure — reported with the status code. The script never substitutes a different geography to paper over a transport error.

What the script never does

  • It never mixes geography tiers within one ladder (no splicing a national 90th onto a local 25th).
  • It never treats a capped/censored figure as if it were a real number.
  • It never guesses an occupation for an unmapped discipline.
  • It never writes to the database, calls a write endpoint, or posts to the audit channel.

After running it

Copy the five printed boundaries into the discipline's salary ladder modal on /console/benchmarks (owner only). Nothing changes until you do — the script's job ends at the terminal.