Every old program eventually has one last person who knows how it really works. In most offices that person is a few months from retirement, and somewhere there's a nightly batch job that only they can explain. For SIERRA-FLOW, my COBOL program that checks eight Sierra Nevada rivers every morning, that person is me. So I decided to do the handoff properly: write down everything it does, rebuild it in C#, and prove the new version prints exactly what the old one does, down to the last byte.

This post covers how that went. It took a lot more reading than writing, and the rivers had a surprise of their own.

Read it like an inheritance

The first rule of taking over someone else's system is not to touch it yet. Before I wrote a line of C#, I read all 1,392 lines of COBOL as if a stranger had written them, and kept notes in a file called HANDOFF.md: what each paragraph does, what the return codes mean, and what to do at 7 a.m. when a gage goes quiet. Then I fed the program things it had never seen, to find out what it does that nobody wrote down.

It turned out to know quite a lot. A few of my favorites:

None of these has ever produced a wrong number. Each one is a trap for whoever changes the job next. The handoff notes list twelve, and every one has a test case that shows it happening.

Copy it faithfully, quirks and all

Here's the part that felt strange: the C# port does every one of those things too, on purpose. If the new program quietly fixes things while you're trying to prove it matches the old one, you can never tell an improvement from a mistake. So first you match it exactly. Then you fix it, one change at a time, each with its own expected difference. Every method in the C# names the COBOL paragraph it replaces, so the two can be read side by side.

Coming from Python, C# felt familiar faster than I expected. Types are declared, braces replace indentation, using is import, and LINQ reads a lot like a list comprehension. The real lessons were all about numbers:

COBOLC#Why it matters
PIC 9(7)V99decimalBase ten, like the COBOL. A double can't even hold 0.1 exactly.
MOVEPic(v, 7, 2)COBOL truncates when it moves a number, from the right and the left.
COMPUTE ... ROUNDEDMidpointRounding.AwayFromZeroC# rounds half to even by default. COBOL doesn't.
Z(4)9.99Edit(v, 4, 2)The report's columns, space for space.

The C# has no packages at all, just the .NET 8 runtime and three CSV files, the same as the COBOL. It builds offline and runs anywhere .NET runs.

The parallel run

This is the heart of a handoff: run the old system and the new one side by side on the same inputs, and refuse to call it done until they agree. SIERRA-FLOW made that unusually easy, because every morning's run commits its input file to GitHub. Its git history holds every night the job has ever had.

So the test harness replays all of them. For each production night since version 3, it pulls that night's files out of history and runs both programs, with both clocks pinned to the exact second that night originally ran. GnuCOBOL reads a variable called COB_CURRENT_DATE for that, so I taught the C# to honor the same one. Then it compares the report, the results file, every console message and the return code. It also checks that the COBOL replay matches what production really printed that night, which proves the replay itself is honest.

JOB PARALLEL ENDED 29 OF 29 CASES MATCH 3,176 LINES COMPARED 17 production nights · 10 edge cases · 2 NORMALS cases · 17 of 17 replays = production

On Sundays it goes one step further: it downloads 30 years of real USGS history for all eight gages and has both programs build the normals from scratch, 2,928 percentile rows each. The first time it ran, those matched too.

On the new page there's one report split down the middle by a seam you can drag. Green-bar paper from GnuCOBOL is on the left, the C# output is on the right. Because the files are identical, every character lines up perfectly across the seam.

Then I broke it on purpose

A test that has never failed hasn't proven anything yet. So I changed one setting: I let C# round the way it rounds by default, "banker's rounding," where a 5 goes to the nearest even digit. Then I ran everything again.

The parallel run caught it on two real nights. On October 4, the Tuolumne at Grand Canyon's 30-day average came out to exactly 11.805 cfs. COBOL printed 11.81, and the broken C# printed 11.80. On October 15, Pohono Bridge in Yosemite Valley was 61.3% of its median for COBOL and 61.2% for the broken C#. In the normals, 374 of 1,098 rows changed. Nobody reading the report would notice a one-cent difference, and that's exactly why you want a machine checking.

The database checks both, and finds a surprise

The last piece moves the data into SQL Server. The C# writes a T-SQL script that loads each night in one transaction: the gages as geography points, every daily mean, and the night's results. A daily mean that's already in the table goes through a MERGE, and any value that changed gets logged with OUTPUT. Then a second script works out the 30-day averages and trends again from the raw rows and throws an error if they don't match what the batch job wrote. That's three programs in three languages, all required to land on the same answer. Every night, GitHub Actions starts a fresh SQL Server 2022, loads every night in order and runs the check. So far it's 128 gage-nights out of 128.

Then the rivers spoke up. USGS publishes recent flows as provisional, which means they can change after review. On October 5, it revised 55 days of daily flow for the Tuolumne below La Grange Dam, all of them downward, by as much as 16%. September 30 went from 204 cfs to 171. The COBOL job never noticed, because every morning it simply overwrites yesterday's file. The database keeps both numbers. For anyone whose job is keeping many data sources clean and consistent for a lot of people, that's the whole argument for the database in one chart.

What's next

Now the boring, important part: let the parallel run go every night for a while, and only then fix the twelve findings one by one. Validate the flows in the job itself. Widen that first column. Sort each gage's days by date and keep the newest seventy. The old program got a proper send-off, with notes for whoever comes next, and it's still running every morning while its replacement learns the job alongside it. That felt like the right way to do a handoff.

Code: github.com/bdgroves/sierra-flow-handoff · the handoff notes: HANDOFF.md · the original COBOL: bdgroves/sierra-flow-cobol. Written with Claude as a second reader of the COBOL. Every finding was checked by running both programs.


Go deeper

📄 Read

Feathers, M. (2004). Working Effectively with Legacy Code. Prentice Hall. The book on changing code you didn't write and can't fully see.

Strangler Fig Application — Martin Fowler on replacing an old system gradually, letting the new one grow around it while both run.

MidpointRounding — Microsoft's docs on the rounding rule that bit the sabotage test. · Floating-point numeric types — why decimal and not double.

📊 Data & tools

MERGE (Transact-SQL) — the statement that caught the revisions. · STDistance (geography) — distances between gages on the ellipsoid.

USGS provisional data statement — why recent river data can change. · GnuCOBOL — the free compiler still doing the work.

The Handoff — the seam, the parallel-run ledger and the revisions chart. · SIERRA-FLOW — the COBOL, live, still running every morning.