rmhladder — Productionization & Hardening (Design)¶
Date: 2026-07-15
Status: Approved design, pending implementation plan
Location: Inside rmhstudios.com (TanStack Start), extending the existing rmhladder mini-app + ladder-worker
Predecessor: 2026-07-05 rmhladder design — foundation, adapters, pipeline, dashboard, alerts (Plans 1–4, merged to main)
Goal¶
Take the already-built rmhladder job tracker from “works on paper” to reliably running unattended in production. Concretely: fix the two user-visible failures (stale/under-refreshed jobs; broken resume upload), move the scrape to a 12-hour cadence that actually runs, and raise the whole system to an enterprise bar across four pillars the owner selected — reliability & observability, data quality & freshness, coverage & scale, ops & scheduling — without changing the core architecture.
Scope decision (locked with owner): keep direct per-company ATS crawling (the right model for the finance/consulting/tech early-career domain, where aggregators have poor and stale coverage). We harden and broaden it — we do not add a third-party aggregator API, and we do not add new ATS engine types this pass.
Decisions Made¶
Decision |
Choice |
|---|---|
Scrape strategy |
Harden + broaden existing per-company ATS crawling. No aggregator API. No new ATS engine types this pass. |
Where symptoms live |
Production. Phase 0 establishes prod truth before code changes. |
Enterprise scope |
All four pillars: reliability/observability, data quality/freshness, coverage/scale, ops/scheduling. |
Coverage depth |
Deepen existing. Max out Workday coverage + tighten auto-discovery. No iCIMS/Phenom/Eightfold/SuccessFactors/Taleo this pass. |
Resume storage |
S3/R2 object storage (PII-safe). Fix is fail-loud provisioning guardrails, not loosening the storage requirement. |
Cadence |
Every 12 hours ( |
Implementation style |
Subagent-driven development, phased; Phase 0 ships first. |
Runtime |
Unchanged: Node |
Root-Cause Diagnosis (evidence from current main)¶
These are the concrete defects the design targets. Each cites the file that establishes it.
Resume upload fails in production — unprovisioned env surfaced as an opaque 503.
app/routes/api/rmhladder/resume/index.ts:34-37returns503 "Resume storage is unavailable."in production whenevers3Configured()is false.s3Configured()(lib/storage/s3.server.ts) requires all ofS3_BUCKET / S3_ENDPOINT / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY. Resumes are PII, so the route deliberately refuses the local-FS fallback other uploads use. A second prerequisite,LADDER_RESUME_ENCRYPTION_KEY(lib/rmhladder/resume/crypto.server.ts), must also be verified. Net: if prod env is incomplete, every upload fails silently to the user.Stale jobs never expire — the empty-board blind spot.
lib/rmhladder/pipeline/recheck.tssetsfetchSucceeded = boardJobs.length > 0and issues no strikes when a board returns[](documented MVP tradeoff in the file header: “A genuinely-emptied board will not auto-expire via recheck”). When a company pulls all early-career reqs — routine in finance’s seasonal cycle — its jobs stayactiveindefinitely. This is the primary “doesn’t properly refresh jobs” cause.The flagship finance firms yield ~no jobs — manual sources are aliveness-only.
lib/rmhladder/pipeline/run.tsStep 5 treatsmanualsources (Goldman, JPMorgan, Morgan Stanley, the bulge brackets, big consultancies — seeded viaMANUAL_EARLY_CAREER_URLSinlib/rmhladder/seed/companies.ts) with only a landing-page aliveness ping plus opportunistic Workday-tenant sniffing. Priority-1 companies therefore contribute little real inventory.Cadence is 4h by default and the staleness constant is decoupled.
lib/rmhladder/scheduler.ts:DEFAULT_LADDER_CRON = '0 */4 * * *',DEFAULT_STALE_AFTER_MS = 4h. Changing cadence to 12h without aligning the staleness threshold makesisScrapeStale(used by the worker bootstrap) misjudge freshness.Deploy uncertainty. The
ladder-workerservice is present indocker-compose.ymland built bypnpm build, but whether it is actually running and provisioned in prod is unconfirmed (owner has dashboard/log access; prior notes suggest the deploy Action may be dormant). If the worker isn’t running, that alone explains symptoms 1–3. Phase 0 resolves this before any code lands.
Non-Goals (this pass)¶
No aggregator/third-party jobs API; no scraping strategy change.
No new ATS engine types (iCIMS, Phenom, Eightfold, SuccessFactors, Taleo).
No Redis/BullMQ/queue; keep the single-process
node-cronmodel.No changes to scoring, classifiers, alert channels, or the resume AI-review flow beyond what’s needed to unbreak upload.
No horizontal scaling of the worker (the DB lease already guards single-owner execution).
Phase 0 — Prod truth + acute fixes (ship first)¶
Intent: make the two visible bugs die and put the schedule where the owner wants it, on confirmed-running infrastructure.
0.1 Establish prod truth (owner-assisted, read-only)¶
Before code, capture the current production state from /rmhladder/health, /rmhladder/pipeline, and ladder-worker logs:
Timestamp of the last completed
LadderScrapeRun(and whether the worker logs[ladder-worker] Run completeat all).Active vs. expired
LadderJobcounts; source counts by status (active/unconfigured/error/blocked).The exact status/error returned by a real resume-upload attempt (confirms the 503-storage theory).
Which prod env vars are set:
S3_*,LADDER_RESUME_ENCRYPTION_KEY,LADDER_CRON_SCHEDULE, AI provider key.
Output: a short findings note recorded in the plan’s progress ledger. This gates whether Phase 0 is “provision + guardrail” or also “the worker was never running.”
0.2 Resume upload — fail loud, provision correctly¶
Add a startup self-check in the web tier (and a check surfaced on
/rmhladder/health) that reports resume-subsystem readiness: object storage configured + encryption key present. Missing config becomes a named, visible error — not a per-request silent 503.Improve the upload route’s error payload so a misconfigured backend returns an operator-actionable message (still 503, but naming the missing capability) while never leaking secret values.
Deliver a provisioning runbook (
docs/): exact env vars, how to point at R2/S3, how to generateLADDER_RESUME_ENCRYPTION_KEY, and a verification curl. Keep object-storage-required for PII (no local-FS fallback in prod).Tests: readiness check returns correct verdicts for (all set) / (storage missing) / (key missing); route returns the actionable error when unconfigured and
201when configured (existing upload test path).
0.3 Schedule → 12 hours¶
Change
DEFAULT_LADDER_CRONto'0 */12 * * *'and setDEFAULT_STALE_AFTER_MSto track cadence (12h) soisScrapeStaleand the bootstrap stay coherent.LADDER_CRON_SCHEDULEstill overrides.Document the 12h default in
.env.exampleandserver/CLAUDE.md.Add/confirm a manual “Run now” trigger usable from the dashboard (admin-gated) so operators aren’t forced to wait a cycle. (If a manual-trigger path already exists via
pnpm ladder:run, expose/verify it; do not duplicate.)Tests:
resolveLadderCronaccepts the new default;isScrapeStaleboundary at 12h.
Phase 1 — Scraping correctness & freshness (data-quality pillar)¶
Intent: eliminate the empty-board blind spot so dead postings actually expire, with a defense-in-depth age backstop, without regressing the mass-expiry protection.
1.1 Explicit fetch-success signal¶
Adapters’ discovery return an explicit fetch-success indicator distinct from “zero jobs found,” so recheck can tell “board fetched OK and is genuinely empty” (→ jobs should strike/expire) from “fetch failed/ambiguous” (→ skip, no strikes). This removes the
boardJobs.length > 0heuristic inrecheck.ts.Preserve the mass-expiry circuit breaker (
circuitBreakerinrecheck.ts): a fetch that legitimately returns empty for a source with many active jobs still trips the breaker → sourceerror+mass_expiry_suspectedreview task, rather than nuking inventory.Tests: empty-but-successful board issues strikes and expires at the 3-strike threshold; failed fetch still skips; breaker still trips on mass disappearance.
1.2 Last-seen age backstop¶
Add a time-based backstop:
activejobs whoselastSeenAtexceeds a configurable freshness window (env-tunable, default aligned to cadence × a safety multiple) are expired even if a source is chronically ambiguous. This bounds worst-case staleness.The backstop respects the circuit breaker’s intent (does not mass-expire when a whole source is failing).
Tests: a job past the age window expires; a recently-seen job does not; interaction with the breaker is covered.
1.3 Freshness visibility¶
Surface per-source freshness (last successful discovery, last job seen) so Phase 3’s dashboard/alerting can consume it. (Schema likely already carries
lastSuccessAt/lastSeenAt; confirm and fill gaps rather than duplicating.)
Phase 2 — Coverage & scale, by deepening (coverage pillar)¶
Intent: convert the priority-1 manual finance/consulting firms into real job-yielding sources by maximizing the ATS engines we already support — chiefly Workday — and by strengthening auto-discovery. No new ATS engine types.
2.1 Maximize Workday coverage¶
Strengthen Workday tenant/site auto-discovery from company career landing pages (extend the existing
discoverWorkdaySourceUrlspath inrun.tsStep 5 and the Workday adapter/prober): find the correct CXS endpoint(s) for firms that use Workday but weren’t detected, validate against the official endpoint, and activate them as normal API sources.Handle multi-site Workday tenants (a firm may split campus/experienced sites).
Tests: discovery picks the right CXS endpoint(s) from representative landing-page fixtures; a firm known to be Workday flips from
manualto an activeworkdaysource.
2.2 Auto-discovery robustness¶
Make the slug prober / source-configuration path self-healing across runs: unconfigured and error sources are retried with bounded backoff (
nextProbeAtalready exists), and successful discovery is idempotent (no duplicate sources —companyId_platform_sluguniqueness already enforced).Ensure the worker’s
probeUnconfiguredSourcesbatch continues making bounded progress every run (already wired inserver/ladder-worker/index.ts; verify batch sizing and coverage math against the seed size).Tests: repeated runs converge unconfigured→active without duplication; error sources back off and retry.
2.3 Coverage reporting¶
Emit a coverage snapshot per run (companies with ≥1 active source, companies still manual/unconfigured, jobs per firm-type) for the dashboard and for tracking progress toward “the flagship firms actually produce inventory.”
Phase 3 — Reliability & observability (reliability + ops pillars)¶
Intent: make it obvious, automatically, when scraping degrades — and make recovery routine.
3.1 Run metrics & history¶
Ensure every run persists the counts it already computes (
LadderScrapeRun: discovered/new/verified/expired/errors, duration, trigger) and expose a run history view (extend existing/rmhladder/pipeline). Add derived health: success rate, error-rate per source, jobs added/expired trend.
3.2 Staleness & failure alerting¶
Operator alerting (reusing existing alert-dispatch infra where possible) when: no completed run in > threshold (worker down/stuck); a run’s error count or per-source error-rate exceeds a threshold; the mass-expiry breaker trips; resume subsystem reports not-ready. Channel: at minimum in-app/health surface + the existing notification path; email/Discord if cheap to reuse.
Distinguish worker-down (no run rows appearing) from runs-failing (rows with high errors).
3.3 Resilience¶
Per-source retry/backoff already partially present (
nextProbeAt,consecutiveFailures); make policy explicit and bounded, and ensure one bad source never blocks a run (already per-source try/catch — verify and cover).Harden
/rmhladder/health: a single at-a-glance operator page — worker liveness (last run age vs. cadence), source health distribution, freshness SLA, resume-subsystem readiness.
3.4 Runbook¶
docs/runbook: how to read the health page, how to force a run, how to interpret/clear review tasks and a tripped breaker, resume-storage provisioning (cross-link Phase 0), and the env-var reference.
Data Model¶
Prefer reusing existing Ladder* fields; introduce new columns only where a signal genuinely has nowhere to live (candidate: a per-source lastSuccessfulDiscoveryAt if not already derivable; a freshness/age marker if lastSeenAt is insufficient). Any schema change is additive and ships via pnpm db:migrate (prod runs db:migrate:prod on deploy). The implementation plan enumerates exact fields after confirming the current schema; no field is added speculatively.
Testing¶
Follow the existing rmhladder discipline: colocated .test.ts, pure decision cores unit-tested with injected fakes (as recheck.ts/run.ts already do), fixtures for adapter/discovery parsing. Every phase’s task lands with tests green (pnpm exec vitest run), no new type/lint warnings (pnpm exec tsc --noEmit, pnpm lint). TDD per task (subagent-driven-development default).
Rollout & Ops¶
Phased merge: Phase 0 first (unblocks users), then 1 → 2 → 3, each behind green CI.
Env additions documented in
.env.example; prod provisioning via the Phase 0 runbook.Cadence and thresholds are env-tunable so ops can adjust without a deploy.
Deploy path unchanged (
main→ Actions →deploy.sh→ docker-compose); if the deploy Action is dormant, Phase 0 surfaces it and the runbook covers a manual deploy.
Compliance (unchanged, restated)¶
Honor robots.txt for all non-API fetches, custom User-Agent (LADDER_USER_AGENT), 1 req/sec/domain politeness (LADDER_DOMAIN_RATE_LIMIT_MS), no CAPTCHA/login-wall/anti-bot bypass. Broadening coverage must not weaken these guarantees.
Open Questions (resolve during Phase 0)¶
Is
ladder-workeractually running in prod, and is its env complete? (0.1 answers this; it may reduce Phases 1–3 to “hardening on already-working infra” or reveal a dead worker.)Alerting fan-out: reuse the user-facing alert channels for operator alerts, or a lighter in-app/health-only surface? (Default: start with health surface + existing notification path; expand only if cheap.)