Case Study · From our founder's work
Migrating 5,689 Employees from Dayforce to UKG Pro for $3.58
A serverless HRIS migration pipeline — Dayforce HCM API to BigQuery to UKG Pro — built solo in 38 days against a cutoff someone else set. Total cloud spend across the entire project: $3.58.
5,689
Employees migrated
4,158 US · 1,531 Canada
38
Days, code to cutoff
fixed date, set externally
$3.58
Total cloud spend
entire project, not monthly
1
Engineer on the pipeline
sole author of extract → export
Jump to — sections marked deep dive are technical and skippable
At a glance
- Scope
- Full HRIS data migration: Dayforce HCM → UKG Pro, US + Canada
- Population
- 5,689 employees (4,158 US / 1,531 Canada)
- Timeline
- First line of code to production cutoff: 38 days
- First working export
- 3 days after project start
- Team on the data pipeline
- 1
- Total GCP spend, entire project
- $3.58
- Data mapped
- 191 output columns across 9 templates, two national tax regimes
- Regeneration time
- Minutes — not a re-migration
The problem
A translation problem wearing a data transfer's clothes
An HRIS migration is not a data transfer. Two systems describe the same human being in incompatible vocabularies. Dayforce says a marital status is Common Law; UKG wants C. Dayforce emits JSON booleans; UKG wants Y and N. Dayforce records a Canadian employee's banking as a 5-digit transit and a 3-digit institution code in separate fields; UKG wants them positioned and zero-padded exactly so, or the payment does not clear.
Multiply that by 191 columns, two countries, and 5,689 people, and the translation problem is the entire project.
The deadline was not negotiable and was not his to set: Dayforce employee self-service locked at 4:00 PM Central on April 10, 2026. Whatever data existed at that moment was the data that would define 5,689 people's employment records in a new system — their pay, their tax withholding, their direct deposit, their seniority.
The conventional approach to this is people. A team maps fields into spreadsheets, hand-builds the load files, and then — because the business does not stop hiring while you migrate — maintains those files by hand as reality drifts away from the extract. That dual maintenance window is where migrations go wrong, and it is the part this build was designed to eliminate.
About this case study
This is work StrAinge Business Solutions founder Hunter Strange built in his HRIS role at a 5,689-employee aviation services company — not a StrAinge Business Solutions client engagement. We publish it because it is the clearest demonstration we have of how we approach a problem: Hunter was the sole engineer on the extraction, transformation, and export pipeline. The surrounding migration was a team effort — an HRIS lead, UKG's conversion consultant who performed the actual load, and a project manager. His scope was everything between Dayforce's API and the files that landed on the consultant's desk.
Architecture
Three functions, one queue, one dataset
Source system
Dayforce HCM · /V1/Employees
OAuth2 · hard ceiling of 100 profile expansions per minute
Google Cloud Platform
Cloud Scheduler
daily 6:00 AM CT
dayforce-master-trigger
Cloud Functions gen2 · 512MB · 540s · enqueues and exits
Cloud Tasks
rate limit enforced by schedule_time arithmetic — nothing is alive while it waits
dayforce-worker-node
Cloud Functions gen2 · 1GB · 25 threads · 19-node expand · ~25s of life
BigQuery · dayforce_raw
raw_employee_hr_data
one column: record JSON — the frozen source of truth
raw_employee_delta_log
append-only audit — post-cutoff writes land here
9 transformation views
vw_ukg_mf1–mf4 · 191 columns · materializes nothing
Cloud Storage
UKG-ready CSV, written to the vendor's exact naming convention
dayforce-delta-reporter
Cloud Functions gen2 · 256MB · daily 6:30 AM CT · field-level diff email to stakeholders
Destination
UKG Pro · MF1–MF4 load files
handed to the vendor's conversion consultant for load
API utilization
The constraint that shaped everything
Dayforce permits 100 employee profile expansions per minute. At 5,689 employees, a full extract is structurally a ~62-minute operation no matter how much compute you throw at it. The limit is the design.
The naive solution is a long-running process that fetches 100 records and sleeps 60 seconds, 57 times over. This fails on serverless immediately: gen2 Cloud Functions cap at 540 seconds. A 62-minute in-process sync cannot exist in a single invocation. An earlier iteration in the repo attempts --timeout=3600s --memory=8192MB — a configuration that exceeds the platform's HTTP limit and cannot deploy. That wall is what forced the redesign.
The move: offload the sleep to the queue.
Rather than any process waiting, the Master function paginates the employee index, chunks the roster into batches of 100, and enqueues each batch onto Cloud Tasks with an explicitly computed future execution time:
# Dayforce allows strictly 100 explicit profiles per minute
chunk_size = 100
...
# 3. Mathematically enforce the 65-second sleep block directly on the queue!
# The first message runs now, the second exactly 65s from now, the third 130s, etc.
timestamp = timestamp_pb2.Timestamp()
timestamp.FromDatetime(now + datetime.timedelta(seconds=65 * total_tasks))
task["schedule_time"] = timestampThe Master exits in seconds having done nothing but enqueue. Each Worker wakes, does roughly 25 seconds of work, and dies. Nothing is alive during the 62 minutes the extraction spans. There is no process to crash, no timeout to exceed, no instance to pay for.
The 65 seconds — rather than 60 — is a deliberate 5-second safety margin per batch against clock skew and Dayforce's own window accounting.
Burning the quota fast, then getting out
Within its 65-second window a Worker has a 100-call budget and every reason to spend it immediately:
# Worker spawns 25 subthreads to instantly process its 100 quota
with ThreadPoolExecutor(max_workers=25) as executor:Twenty-five threads clear a 100-record batch in roughly 20–30 seconds, leaving 35–45 seconds of headroom inside the window — headroom that is then deliberately spent on a serial retry pass for transient failures, so that a flaky record is recovered within its own batch rather than lost or deferred.
The threads share a single module-global requests.Session:
# Global TCP Session Configuration (Eliminates SNAT Port Exhaustion)
session.mount("https://", HTTPAdapter(pool_connections=30, pool_maxsize=30))Retry taxonomy: knowing what's worth retrying
Under a hard rate limit, a wasted retry is stolen throughput. So failures are classified rather than blanket-retried:
if reason.startswith("http_5"): return True
if reason == "http_200_null_data": return True
if reason.startswith("timeout_"): return True
if reason.startswith("exception_"): return True
if reason.startswith("future_exception:"): return True
return FalseThe docstring states the intent plainly: "Excludes 4xx responses (genuinely bad records) but includes 5xx, timeouts, exceptions, and 200-with-null-Data." A 404 will be a 404 again — retrying it burns quota that a recoverable record needs. Note http_200_null_data: Dayforce will occasionally return HTTP 200 with a null payload. That is a lie, and the pipeline treats it as one.
Rate-limit responses are handled reactively as a second layer, honoring the server's own guidance:
if resp.status_code == 429:
sleep_time = int(resp.headers.get("Retry-After", 10))
time.sleep(sleep_time)
continueThe "god payload"
The single highest-leverage API decision was refusing to fetch narrowly. Every employee is retrieved with a 19-node expansion:
Addresses,Contacts,DirectDeposits,EmploymentStatuses,OrgUnitInfos,
CompensationSummary,PayGradeRates,USFederalTaxes,USStateTaxes,USTaxStatuses,
CANFederalTaxes,CANStateTaxes,CANTaxStatuses,WorkAssignments,
LastActiveManagers,EmployeeProperties,EmergencyContacts,Ethnicities,MaritalStatusesThis grew empirically. A dedicated API investigation harness probed every candidate endpoint and diffed the results against the then-current 14-node expansion, recommending five additions that landed.
The reasoning is economic. Under a 100/min ceiling, a re-extract costs an hour. Field mappings churn constantly during a migration — a stakeholder asks for W-4 Step 2(c) on April 9, or an address field turns out to live somewhere unexpected. If the extract were scoped to known requirements, every discovery would cost an hour and a redeploy. By over-fetching every available node once and deciding what it means later in SQL, new requirements cost a view change and nothing else.
Cloud platform
Schema-on-read: the load-bearing decision
The primary table has exactly one column.
job_config = bigquery.LoadJobConfig(
schema=[bigquery.SchemaField("record", "JSON")],
)The entire Dayforce employee object lands verbatim in record as a native BigQuery JSON type. No flattening, no staging schema, no ETL mapping at ingest.
This is the architectural counterpart to the god payload, and together they define the system. Extraction is expensive and rate-limited; transformation is free and instant. So extraction is made dumb and total — grab everything, interpret nothing — and every ounce of interpretation is pushed into SQL views that can be rewritten and redeployed in seconds against data already sitting in BigQuery.
The shape of the answer can change without re-asking the question. Over eight production iterations, the mapping logic was rewritten continuously. The extraction was not touched.
Service roles
| Service | Role |
|---|---|
| Cloud Scheduler | Two cron triggers: daily delta sync (6:00 AM CT), delta report (6:30 AM CT) |
| Cloud Functions gen2 | Master (512MB/540s), Worker (1GB/540s), Reporter (256MB/120s) |
| Cloud Tasks | Rate-limit enforcement via schedule_time; durable retry substrate |
| BigQuery | Landing zone (raw JSON), transformation engine (9 views), audit log |
| Cloud Storage | UKG-ready CSV delivery under the vendor's exact naming convention |
| Secret Manager | Credentials for the delta report mailer |
Idempotency
Workers are dispatched by a queue, and queues redeliver. So writes converge rather than accumulate:
MERGE `{table_id}` T
USING `{stage_table_id}` S
ON JSON_EXTRACT_SCALAR(T.record, '$.XRefCode') = JSON_EXTRACT_SCALAR(S.record, '$.XRefCode')
WHEN MATCHED THEN UPDATE SET record = S.record
WHEN NOT MATCHED THEN INSERT (record) VALUES(S.record)Each batch stages into a UUID-named temp table, MERGEs on the employee's cross-reference code extracted from the JSON itself, and drops the stage.
Observability decisions that mattered
Batch status is written unconditionally — including when every single fetch in the batch failed. The comment explains why: "This is the only signal the reporter has to tell ‘all-batches-failed’ from ‘no-batch-ran’." Silence is ambiguous; a failure record is not. Under a 62-minute distributed extract with no live operator, that distinction is the difference between a caught problem and a shipped one.
Conversely, the audit-log append is wrapped so it cannot break ingestion: "Audit failure must NOT block primary ingestion." Observability that takes down the thing it observes is worse than none.
Data transformations
Where the migration actually lives
| Output | Columns | Notes |
|---|---|---|
| vw_ukg_mf1_employee_us | 106 | Demographics, org, compensation, federal + state tax |
| vw_ukg_mf1_employee_ca | 80 | SIN expiry, union locals, CPP/QPP/EI, LEEP reporting |
| vw_ukg_mf2_deduction_us / _ca | 17 each | Deductions |
| vw_ukg_mf3_directdeposit_us / _ca | 13 each | ABA vs. Canadian EFT |
| vw_ukg_mf4_earnings_us / _ca | 12 each | Earnings |
| vw_w4_step2_us | 4 | Standalone — no MF1 column exists for it |
2,703
Lines of SQL
17,841
Lines of Python
492
Lines in the US MF1 view
25 CASE expressions · 206 WHEN branches
0
Staging tables
everything shreds inline from record
The US MF1 view alone contains 127 JSON_EXTRACT_SCALAR calls — reading raw JSON and emitting a 106-column, load-ready row.
State tax filing status: 39 regimes, no shared vocabulary
The hardest single problem. US state withholding codes are not standardized — each state invented its own, and Dayforce stores a human-readable description while UKG demands the state's specific code:
(SELECT CASE
-- === AL: H, M, N, S, W ===
WHEN sc = 'AL' AND fs = 'Single' THEN 'S'
WHEN sc = 'AL' AND fs = 'Married, Self Separate' THEN 'W'
-- === AZ: Percentage-based codes A-H, Z; Dayforce doesn't capture % election ===
WHEN sc = 'AZ' THEN 'Z'
-- === CT: Withholding codes A-F, N, X ===
WHEN sc = 'CT' AND fs = 'Single' THEN 'F'
-- === MS: A, B, C, D, N, X, Y ===
WHEN sc = 'MS' AND fs = 'Single' THEN 'A'
-- === NJ: Rate codes A-E ===
WHEN sc = 'NJ' AND fs = 'Single' THEN 'C'
...
WHEN fs LIKE 'Married%' THEN 'M'
ELSE 'S'
END"Single" maps to S in Alabama, A in Mississippi, C in New Jersey, and F in Connecticut. Arizona collapses to Z because its scheme is percentage-based and the source system does not capture the election at all. 39 distinct state tax codes × 9 Dayforce filing statuses = 103 mappings, each one a small decision with a real consequence for someone's paycheck.
Every mapping is documented twice — once as executable SQL and once as a reviewable CSV carrying a justification column, so a payroll stakeholder could audit the lossy ones without reading SQL:
CASIT,Married,S,"SINGLE/MARRIED 2 or MORE INCOMES",Conservative default; Dayforce does not distinguish one vs two incomes
AZSIT,Single,Z,NO FORM A-4,AZ uses percentage-based codes (A-H); Dayforce does not capture % election
FLSIT,Head of Household,S,SINGLE,FL has no HOH code; default to SWhere information is genuinely lost, the mapping is conservative and the loss is written down. That is the difference between a migration and a data-loss event.
Canada is not "US minus fields"
A separate tax regime with its own vocabulary. Temporary SIN detection: Canadian temporary/non-resident Social Insurance Numbers begin with 9 and carry an expiry date; permanent ones do not. Emitting an expiry for a permanent SIN is a data error, so the view branches on the leading digit:
CASE WHEN LEFT(JSON_EXTRACT_SCALAR(record, '$.SocialSecurityNumber'), 1) = '9'
THEN FORMAT_DATE('%m/%d/%Y', CAST(SAFE_CAST(JSON_EXTRACT_SCALAR(record, '$.SSNExpiryDate') AS TIMESTAMP) AS DATE))
ELSE NULL END AS SINExpirationDate,Canadian EFT decomposition, with a defensive filter that caught real corruption:
-- Canadian EFT: RoutingTransitNumber stores the 5-digit transit alone; BankNumber
-- holds the 3-digit institution code (RBC=003, TD=004, Scotia=002, CIBC=010, BMO=001, ...).
CONCAT('="', LPAD(JSON_EXTRACT_SCALAR(dep, '$.RoutingTransitNumber'), 5, '0'), '"') AS TransitorBranch,
CONCAT('="', LPAD(JSON_EXTRACT_SCALAR(dep, '$.BankNumber'), 3, '0'), '"') AS InstNum,
...
-- Drop rows lacking a Canadian institution code or with non-5-digit transits
-- (US ABA 9-digit routings mixed into CA direct deposits cannot clear through
-- Canadian EFT). Surfaced separately as exceptions for triage.
AND JSON_EXTRACT_SCALAR(dep, '$.BankNumber') IS NOT NULL
AND LENGTH(JSON_EXTRACT_SCALAR(dep, '$.RoutingTransitNumber')) = 5Canadian direct deposit records in the source system contained US 9-digit ABA routing numbers — payments that would never have cleared. The pipeline refuses to emit them and routes them to a human-readable exception file instead of silently shipping broken banking data.
The details that decide whether a load succeeds
Excel text-armoring
Every identifier is wrapped so that no tool between BigQuery and UKG eats a leading zero:
CONCAT('="', LPAD(JSON_EXTRACT_SCALAR(record, '$.XRefCode'), 6, '0'), '"') AS SrcEmpNo,
CONCAT('="', LPAD(JSON_EXTRACT_SCALAR(record, '$.SocialSecurityNumber'), 9, '0'), '"') AS SSN,Termination gating
TermDate and TermReason null themselves out when the employee is active, rather than trusting the source field — because Dayforce retains stale termination dates on rehired employees:
CASE
WHEN UPPER((SELECT ...EmploymentStatus.XRefCode... LIMIT 1)) IN ('ACTIVE','INACTIVE','LOA','PRESTART')
THEN CAST(NULL AS DATE)
ELSE CAST(SAFE_CAST(JSON_EXTRACT_SCALAR(record, '$.TerminationDate') AS TIMESTAMP) AS DATE)
END AS TermDate,Both W-4 generations, side by side
Pre-2020 FedExemptions alongside 2020+ FedDependentAmt, FedOtherIncomeAmt, FedDeductionAmt, plus W4EffectiveDate so the destination can tell which form vintage governs. Step 2(c) had no MF1 column at all, so it ships as a standalone view whose WHERE clause is a byte-for-byte copy of MF1's — guaranteeing the two files reconcile row for row.
Automated address repair
Dayforce address lines were inconsistently populated across 5,689 records — duplicated, split mid-address, or overlapping. A four-rule engine repairs what it can prove and flags what it cannot:
- Exact duplicate — normalize both lines, if equal, clear line 2
- Substring overlap — one contains the other and the shorter is ≥6 normalized chars; keep the longer. The 6-char floor stops a legitimate APT 2 from nuking a real address line
- Split address — line 1 is a bare street number (295, 311 147, 1806-15, handling en- and em-dashes) and line 2 starts with a letter; concatenate
- Standalone address in line 2 — ≥15 chars, contains a digit, ≥3 words, has a comma or matches one of 26 street suffixes → flag for human review, change nothing
Rule four is the one that matters. The engine repairs 167 records automatically and refuses to guess on 20, routing them to a review file. Automation that knows the boundary of its own confidence is automation you can ship.
The delta isolation pipeline
Eliminating the dual-maintenance window
This is the part that is usually impossible, and it is the reason the exports were current at the moment of ingestion.
The dual-maintenance problem. At 4:00 PM on April 10 the source system freezes and the extract is taken. But the business does not freeze. People are hired, terminated, promoted, and change their banking during the days or weeks between extract and load. Conventionally the migration team now maintains two realities by hand — the frozen load files and a running list of everything that changed since — and reconciles them manually at ingestion. It is laborious, it is where errors are introduced, and it scales linearly with the delay.
The design: keep syncing. Route the writes somewhere else.
The Master accepts a WriteMode parameter. Before cutoff, workers MERGE into raw_employee_hr_data — the frozen source of truth for the load files. After cutoff, the exact same code path INSERTs into raw_employee_delta_log, an append-only audit table.
Switchover
?SyncMode=Delta&WriteMode=DeltaLog
One URL parameter. No redeploy, no branch, no second pipeline to maintain.
Watermarking makes it safe to run continuously — the Master reads MAX(watermark_ts) from a dedicated table and advances it only on delta-mode runs, so "a primary-mode rerun should not move the post-freeze watermark." A 25-hour look-back covers the empty-table cold start.
A fourth function runs 30 minutes after each sync, diffs the delta log against the frozen snapshot at field level, suppresses byte-identical rows as suppressed_duplicates (Dayforce's filter boundaries are inclusive, and humans rerun things), and emails a change report to stakeholders daily.
The catchup snapshot
When it came time to load post-cutoff arrivals, a single command reconstructed a point-in-time view of exactly the right cohort:
python3 build_catchup_frozen.py 2026-04-27It builds raw_employee_hr_data_<YYYYMMDD> from the delta log with a timezone-correct end-of-day Central bound and two exclusions — employees already present in any prior frozen snapshot, and anyone whose latest status is TERMINATED (never onboard someone into a new HRIS on their way out the door). Prior snapshots are auto-discovered via INFORMATION_SCHEMA rather than hardcoded, and the script refuses to run without them:
if not prior:
sys.exit("No prior frozen tables found — refusing to build (would overlap raw_employee_hr_data).")And here is the part that eliminates dual maintenance entirely. The matching MF1 views are generated from the live view SQL by string substitution:
swapped = sql.replace(source_table, target_table)
swapped = swapped.replace(f"vw_ukg_mf1_employee_{country}",
f"vw_ukg_mf1_employee_{country}_{date_tag}")Diffing the generated catchup view against the live view yields exactly 2 changed lines out of 492: the view name and the source table. There is precisely one copy of the mapping logic in existence. It cannot drift, because there is nothing for it to drift from.
All 106 columns of tax translation, address logic, date gating, and boolean coercion are inherited rather than re-typed. Downstream audit tooling then discovers the new views automatically:
SELECT table_name FROM `{PROJECT}.{DATASET}.INFORMATION_SCHEMA.VIEWS`
WHERE REGEXP_CONTAINS(table_name, r'^vw_ukg_mf1_employee_(us|ca)(_\d{8})?$')Add a catchup cohort; every audit script picks it up with no edits. The first catchup snapshot captured 56 post-cutoff new hires — 44 US, 12 Canada — as a load-ready file, built from data the pipeline had been quietly collecting since the moment the source system froze.
Speed
38 days, evidenced by self-dating artifacts
The export script stamps its own execution timestamp into every filename it writes; filesystem mtimes corroborate each one. These are machine-generated receipts, not recollection.
Mar 3
First line of code. API probes, auth, schema discovery.
Mar 6, 06:08
First working export produced — 3 days in.
Mar 10
Formal scope: US + Canada action plans, GCP implementation plan.
Mar 12
Full template review with stakeholders.
Mar 20
API investigation complete — every required field located.
Mar 23 → Apr 10
Six further production iterations.
Apr 6
Delta isolation pipeline designed and shipped.
Apr 10, 16:00
Source system cutoff.
Apr 10, 16:44:55
Final production extract — 5,689 employees, all templates.
38 days from zero to a production cutoff serving 5,689 employees, against a fixed date set by someone else. The final extract was in hand 45 minutes after the source system locked.
Eight full pipeline iterations in 35 days, each one a complete regeneration rather than a hand-patched delta — and each visibly adding capability: census audit sidecars, then the W-4 Step 2 view, then address review, then address change logs.
The field specifications alone — every one of 191 columns documented with source path, transformation rule, decision provenance, and open items — run to 3,471 lines across the US and Canada plans, versioned V1 through V9 in 27 days as requirements were discovered in meetings and resolved in code.
The compounding effect showed up in what new requirements cost:
Yeah, if I've got a translation table, then it's super fast. I mean, it's negligible — no work for me for all practical purposes.
On the final extract itself, in front of the project team:
Hunter:"That whole process of the final API call and preparing the CSVs is a three minute operation."
Project manager:"Okay. Wow. Yeah, that's fast."
The number that matters most, though, is the one about what was otherwise achievable at all:
It is a good thing I built this tool because pulling this information on four eleven as of four eleven and providing it same day, would be literally impossible without the tool. I mean, just one hundred percent out of the question.
That is the real claim, and it is a modest one. Not that the pipeline made a manual process faster — that it made a different process possible. Same-day delivery of a fully-transformed, 191-column, two-country, load-ready dataset at the exact moment of a hard cutoff is not a thing you do by hand at any speed.
Cost
$3.58. Total. For the entire project.
Not per month. Not per environment. The complete Google Cloud spend to extract, land, transform, audit, and deliver a 5,689-employee HRIS migration across two countries — plus the ongoing delta pipeline that ran daily afterward.
It is not a trick, and it is not a free-tier accident. It is what the architecture costs when the architecture is right:
Nothing runs when nothing is happening.
Three Cloud Functions and a queue. The system's resting state is zero compute. Between the 6:00 AM sync and the next one, the bill is storage.
The expensive resource was never Google's.
The binding constraint was Dayforce's 100 calls/minute. The compute needed to saturate a 100-call-per-minute API is trivial — a 1GB function awake for 25 seconds, 57 times a day. Sizing infrastructure to a rate limit rather than to a data volume is why the number is this small.
The queue does the waiting, and waiting is free.
The 62 minutes a full extract spans are 62 minutes in which nothing is billable. Cloud Tasks holds the schedule; no instance holds a thread. This is the single largest cost decision in the build — an equivalent design with a long-running worker would have paid for an hour of idle compute per sync, forever.
BigQuery is charged for what you ask, not what you keep.
~6,000 JSON documents is a rounding error in storage. Transformation is nine views — computed on demand, materializing nothing, storing nothing.
Schema-on-read made iteration free.
Eight full mapping rewrites cost eight redeployed views. The alternative — re-extracting to reshape a staging schema — would have cost an hour of API time and a pipeline run each. The god payload was purchased once.
The comparison worth making is not against another cloud bill. A single hour of consulting time on this project costs more than the entire cloud infrastructure did.
What made it work
Four decisions carried the project
All four are transferable.
Separate the expensive resource from the cheap one, then over-buy the cheap one.
The API is rate-limited and precious; BigQuery storage is effectively free. So fetch everything available in one pass and interpret it later. The god payload plus schema-on-read meant that in eight iterations of continuously churning requirements, the extraction layer was never touched. Discovery cost a view change, not an hour.
Push the constraint into infrastructure, not code.
The rate limit lives in Cloud Tasks' schedule_time, not in a sleep(). That single inversion is what let a 62-minute operation run on a platform with a 9-minute ceiling, at zero idle cost, with no process alive to fail.
Generate, never duplicate.
The catchup views differ from production by two lines because they are produced from it. Downstream tooling finds them through INFORMATION_SCHEMA rather than a config file. There is one copy of the business logic, so there is nothing to keep in sync — which is precisely why the exports could be regenerated current at ingestion instead of hand-maintained against drift.
Automate to the edge of confidence, then stop.
The address engine repairs what it can prove and flags what it cannot. Canadian direct deposits with US routing numbers are refused, not shipped. Lossy tax mappings are conservative and documented in a file a payroll analyst can read. In a domain where a silent error becomes a wrong paycheck, knowing where the automation ends is a feature of the automation.
The through-line
Treat the migration as a compiler, not a transfer.
Source data in, load files out, business rules as code, deterministic and re-runnable at any moment. Once it is a compiler, "give me an up-to-date export" stops being a project and becomes a command.

Have a system migration that can't slip?
Fixed cutoffs, two systems that don't speak the same language, and no room for a data-loss event. That's the shape of problem we build for. Let's talk about yours.
Book a Strategy Consult