Demo · CardDemo · Engineering record
How CardDemo was modernized, step by step.
Every step, tool and check behind the CardDemo case study, in the order it was done: what was run, how it was checked, what came out, and what went wrong on the way. Written for engineers who want to know exactly how the results were produced.
- COBOL programs inventoried
- 44
- batch jobs rewritten in Java
- 3
- lines of COBOL behind them
- 1,716
- differences from the original
- 0
- defects and business questions found
- 8
1 · Overview
The method in one table
The approach is the same for every slice: run the original unchanged, keep everything it produces as the reference, check that reference independently, rewrite, and compare every output until nothing differs.
| Phase | What was done | Result |
|---|---|---|
| Inventory | Read every program, copybook and job; mapped which programs read and write which files; listed risks with file and line references. | 44 programs, about 22,900 lines, 13 data files; 12 risks; six slices proposed. |
| Reference runs | Ran the unchanged COBOL on Linux with GnuCOBOL, with a pinned clock, and saved every output with checksums. | Repeatable results, byte-identical on two compiler versions. |
| Independent check | Re-implemented each job's rules in Python straight from the COBOL and compared with every reference run. | Caught a wrong first reference run. |
| Edge cases | Crafted records for every rule and boundary the sample data never reaches; expected results worked out by hand first. | 21 extra transactions and accounts across three jobs. |
| Rewrite | Java 21 ports that read and write the same records, quirks included; fixes behind switches, off by default. | Identical on every test case. |
| Findings | Every behavior a rewrite could "fix" by accident written up, with the exact records a fix would change. | 8 findings for the business. |
| Database | Typed data model, lossless migration to PostgreSQL, jobs on the database, all-or-nothing saves. | 19 of 19 checks identical. |
| On AWS | The whole chain, COBOL and Java side by side, on serverless AWS, deployed from GitHub. | Public at legacyparity.com/lab. |
| First online screen | The unchanged CICS program run under stand-ins for its CICS commands; a Java service and web page; the two compared screen by screen. | 76 screens identical but one explained exception; 5 findings. Public at legacyparity.com/lab-screen. |
| A screen that writes | The update program under stand-ins that also lock, rewrite and roll back; the Java service's saves in one transaction; screens and saved records compared. | 389 screens and every saved record identical but one explained case; 8 findings. Public at legacyparity.com/lab-update. |
Built with AI assistance
The work was directed and reviewed by Legacy Parity, with an AI coding assistant writing much of the code, scripts, tests and documentation. Its output is held to the same standard as anything else on this page: every result comes from running the code, compared byte for byte with the original COBOL and checked against independent models. Nothing rests on the assistant's word. The final runs were repeated on our own machine (GnuCOBOL 3.2, PostgreSQL 16), and the method itself, a reference run of the unchanged original and exhaustive comparison, is what makes AI-written code safe to trust here. More in AI and data handling.
2 · Tools
What it was built with
| Tool | Version | Used for |
|---|---|---|
| GnuCOBOL | 3.1.2 and 3.2 | Compiling and running the unchanged COBOL programs on Linux |
| Python | 3 | The test harness: input preparation, the sort-step stand-in, summaries, comparisons and the independent models |
| Java | 21 (OpenJDK) | The rewritten jobs, record-level and on the data model |
| Maven, JUnit | 3, 5 | Builds and unit tests |
| PostgreSQL, JDBC driver | 16, 42.7 | The database step, locally and on Amazon Aurora |
| Ubuntu | 24.04 (WSL locally) | Where everything runs, and the base of the AWS container image |
| Docker | The container image that runs both COBOL and Java on AWS Lambda | |
| AWS CDK | 2 (TypeScript) | All AWS infrastructure, as code |
| GitHub Actions | Checks on every pull request, deploy on merge; AWS access through OpenID Connect, no stored keys | |
| AI coding assistant | Drafting code, scripts, tests and documentation, reviewed and verified by running everything against the original |
AWS services in the Parity Lab
| Service | Role |
|---|---|
| Amazon CloudFront, S3, Route 53, ACM | Serve the site and route /api to the API, over HTTPS |
| Amazon API Gateway | The lab's API, with per-route request limits |
| AWS Lambda | A small function that starts runs and reports progress; a container function (x86, 2 GB, Ubuntu 24.04 with GnuCOBOL and Java) that runs every batch step, and the account screen when the API invokes it directly |
| AWS Step Functions | Runs the batch in order: each job's COBOL and Java in parallel, compare, next job, report; cleanup on failure |
| Amazon S3 | Run files, deleted after a day |
| Amazon Aurora Serverless (PostgreSQL 16) | The optional database for the Java side; 0 to 1 capacity units, pauses after 5 idle minutes |
| RDS Data API, IAM database authentication, Secrets Manager | One-time database setup without opening network access to the database; the runner signs in with IAM tokens, no stored password |
| Amazon VPC | Private subnets with no internet access and no NAT gateway; S3 through a free gateway endpoint |
| Amazon ECR, CloudWatch Logs | The container image; logs kept a month |
| AWS Budgets | An email alert at $8 of actual or $10 of forecast monthly charges |
3 · Step by step
What was done, in order
-
Inventory the system
Every COBOL program, copybook and JCL job in CardDemo was read and counted: 44 programs, about 22,900 non-blank lines, of which the core application is 31 programs (14 batch, 17 online CICS). For each program: what it reads and writes, which job runs it, whether it compiles with GnuCOBOL. The batch jobs run in a fixed nightly order: posting, interest, backup, merge, statements; the transaction report runs on demand.
The inventory lists 12 risks with file and line references. Two of them, the interest job never updating the last account (R1) and truncating instead of rounding (R2), were later confirmed by the tests. It also proposed six slices, batch first, because a batch job reads files and writes files and can be compared record by record.
-
Run the original without a mainframe
Each job's COBOL program is compiled unchanged with GnuCOBOL and run on Linux. The harness does what the mainframe environment would:
- Converts CardDemo's sample data from text into fixed-length records, and loads the VSAM files into GnuCOBOL indexed files with the same keys, including the card file's alternate index on account number.
- Maps each JCL DD name to a file through environment variables (
DD_ACCTFILEand so on). - Pins the clock with
COB_CURRENT_DATEto 2022-07-18 10:00, so every run is repeatable. - Passes the job parameter (
PARM='2022071800') the way z/OS does, through a small driver program, and stands in for the Language Environment abend routine (CEE3ABD) with a stub. - Reproduces the report job's sort step (on the mainframe, the sort utility) with a short script.
- Dumps every output file in key order, writes a summary and a manifest with SHA-256 checksums, and keeps the program's printed output.
cobc -x -std=ibm -fsign=EBCDIC -fdefaultbyte=32 -I app/cpy -o CBTRN02C app/cbl/CBTRN02C.cbl bash run_legacy.sh ../source # reference run -> golden/The only value set aside before comparing is the fraction of a second in processing timestamps, which comes from the real clock even when the date and time are pinned.
-
Get the reference right
The first reference run for the posting job looked fine and was wrong: 257 posted, 43 rejected, a normal end. CardDemo stores the sign of an amount in its last digit, mainframe style (
{andA–Iare +0 to +9,}andJ–Rare −0 to −9). Without being told, GnuCOBOL 3.1.2 read $504.77 as $504.70 and a $919.00 refund as a purchase; GnuCOBOL 3.2 read the same data differently and rejected 299 of 300 transactions. With-fsign=EBCDICboth versions agree: 262 posted, 38 rejected.-fdefaultbyte=32pins storage the programs never set (record fillers), which the two compiler versions otherwise fill differently. With both flags, every reference run in all three jobs is byte-identical on GnuCOBOL 3.1.2 and 3.2, and on repeat runs. -
Check the reference independently
For each job, the rules were written again in Python straight from the COBOL source: the posting validations and updates, the interest formula and rate lookup, the report's layout, page breaks and totals. Every reference run is compared with this model, record by record and line by line, and fails on any difference. The model caught the wrong first run above, and changing a single cent in one output record makes the check fail.
-
Test what the sample data never reaches
CardDemo's sample data exercises only part of each program. For each job, extra records were crafted and their expected results worked out by hand before running anything:
- Posting: nine transactions for every rejection rule and boundary: unknown card, card for a missing account, dated on and after expiry, over the limit and expired at once, exactly at the limit, one cent over, a refund.
- Interest: six accounts: an own rate table, a 0% group, a credit balance, a rate table missing a category, an unknown group, and a balance where truncation and rounding differ.
- Report: six transactions on two extra cards: a second card for one account, the largest amount the report can print, a negative adjustment, a zero amount, a transaction from the day before; plus a run with the sort step's dates out of step with the program's.
-
Rewrite each job in Java
Each job became a Java 21 program that reads and writes exactly the same fixed-length records. The ports reproduce everything the original does, including details easy to miss: which rejection code wins when two rules fail, leftover bytes carried from one record into the next, amounts that lose digits on overflow, interest cut off at the cent, the report's number formats. Every behavior that looks like a defect stays as it is by default and has a switch to fix it. The ports have no dependencies beyond the Java runtime.
-
Compare old and new
Both sides run on the same inputs, and a comparison script reads both sets of outputs. Data files are compared record by record and field by field using the copybook layouts, with amounts decoded; the report is compared line by line, all 133 characters; the program's printed output and return code are compared too. The verdict is identical, expected differences (only when a fix is switched on, with the reason stated), or fail.
bash parity_run.sh ../source ../modern/posttran # Java side + comparison -> reports/Job Test data Size Result Posting CardDemo sample / edge cases 300 / 309 transactions Identical / Identical Interest After posting / edge cases 50 / 58 interest charges Identical / Identical Report Nightly data / edge cases / dates out of step 519 / 540 / 537 lines Identical in all three -
Write up what the tests found
Each finding is reproduced by the Java version and documented with the exact records a fix would change, so the business can decide with the evidence in front of it.
Job Finding Effect on CardDemo's data Fix switch Posting Refunds count as extra spending in the credit-limit check 17 of 38 rejections happen only because of it; the fix changes 19 decisions yes Interest The last account in the file never gets its interest $18.77 charged but never added; cycle totals not reset yes Interest Interest is cut off at the cent, not rounded 28 of 50 charges a cent short, $0.28 in total yes Report The last transaction is counted twice in the totals Grand total $79,254.29 instead of $79,233.86 yes Report The last card's total is never printed 49 card totals for 50 cards yes (with the above) Report Pages run on past 20 lines 8 of 18 pages are 40 or 42 lines yes Report "Account Total" is a card total An account with two cards gets two totals no: business decision Report Dates out of step end the report silently No page or grand total, return code still 0 yes -
Move the data into PostgreSQL
A typed data model replaced the record strings: money as exact decimals, dates as dates, ids as numbers, timestamps with a note of the text format they came in (CardDemo uses two). One codec converts between the model and the copybook layouts; all 1,097 records in the 16 files involved convert and come back byte for byte. The three jobs were rewritten on the model, with storage behind one interface: record files or PostgreSQL, one table per file with typed columns.
java ... Main schema --url jdbc:postgresql://localhost:5432/carddemo java ... Main migrate --url ... --in source/app/data/ASCII # load, check, report java ... Main posttran --store db --url ... --daily dailytran.dat --out out python3 run_parity.py ../../source ../../modern/batch # 19 checks- Migration: CardDemo's six data files load and export back byte for byte (226 records). Records that do not fit their types are refused and listed, not changed; none were. The migration's data checks found the interest-rate group sitting in the ZIP code field of all 50 accounts.
- Parity: each job and test case on files through the new model (7) and on PostgreSQL (7), plus the whole nightly chain inside the database: all identical with the COBOL.
- All or nothing: each job saves its changes in one database transaction. A run that stops on a duplicate transaction id, and one where the database refuses the 262nd row after 261 were written, both leave the database unchanged.
- Simpler: the nightly job that merges interest charges back into the transaction file is not needed; each job adds rows and history is kept.
-
Run the whole chain on AWS
The Parity Lab runs the three jobs as a nightly chain on AWS, twice: the original COBOL and the Java versions, on the same data, compared after each job. Each side's later jobs read that side's earlier output, so an error early on would show up again downstream.
- One container image (Ubuntu 24.04, GnuCOBOL 3.1.2, Java 21, the PostgreSQL driver and the harness scripts) runs every step on AWS Lambda. It speaks the Lambda runtime interface through a short Python loop, so it needs no packages beyond Ubuntu's own.
- AWS Step Functions runs the steps in order, with COBOL and Java in parallel for each job; a failure anywhere triggers a cleanup step.
- Database option: the Java side can run on Amazon Aurora Serverless (PostgreSQL 16) in a private network without internet access. Each run gets its own schema. The database pauses after five idle minutes; the jobs retry their connection while it resumes.
- Cost control: nothing runs between visits; runs are limited per second, at three at a time and to 200 a day; run files expire after a day; a budget alert watches the account.
- Delivery: everything is AWS CDK code in the website's repository. Every pull request builds the container image and runs a self-test inside it (five runs of the whole chain on files, seven account screens and eight account updates; more on a database when one is available); merging deploys.
-
Bring the first online screen across
The View Account screen (transaction CAVW, program COACTVWC, screen map COACTVW) is a CICS program: it sends a 3270 screen, ends, and runs again as a new task when the user presses a key, carrying its state in a saved area (the COMMAREA). There is no CICS outside the mainframe, so the harness provides just enough of it:
- Translation: a script replaces each of the program's 15
EXEC CICScommands with a call to a stand-in program and adds the CICS control block to its parameters. Nothing else in the program changes, and a command the script does not know stops it. - Stand-ins: file reads on GnuCOBOL indexed files, including the card file's alternate index by account number, which allows duplicates as on VSAM; sending the screen; receiving what was typed; returning with the saved area; transferring control to the menu. Every call is logged.
- The terminal: a driver runs one task per key press, with fresh working storage each time, as CICS does. It keeps what is on the screen, applies what is typed, sends back the fields the 3270 would send, and draws each screen from the screen map's source: positions, labels, colors, underlines and the cursor.
python3 translate.py COACTVWC.cbl build/COACTVWC.cbl # EXEC CICS -> CALL stand-ins cobc -x -std=ibm -fsign=EBCDIC -fdefaultbyte=32 -fstatic-call -o acctview CICSDRV.cbl build/COACTVWC.cbl CICSSTUB.cbl python3 run_legacy.py build scenarios/scenarios.json golden # 11 scenarios, 76 screens bash parity_run.sh --db jdbc:postgresql://localhost:5432/carddemoThe stand-ins rest on assumptions, listed so they can be checked on a real CICS system:
- Response codes are the documented CICS values: NORMAL 0, NOTFND 13, DUPKEY 15. A read through an alternate index whose key is not unique answers DUPKEY when another record has the same key.
- Input shorter than a field is padded with blanks, the screen map's default.
- The screen map asks the terminal to require all 11 digits of the account number. Terminals that check fields stop a short entry before the program sees it. The driver sends it anyway, so that the program's own check is tested.
- One unnamed field in the map source is out of screen order. It is left out of the drawing.
The Java service uses the same data model as the batch jobs, with the customer file added, on record files or PostgreSQL. It returns JSON for the web page. A separate adapter lays that answer onto the original screen's fields so the two can be compared. The comparison covers 76 screens, field by field, with colors, the cursor, and where control goes on exit.
Run Result Java on record files 10 of 11 scenarios identical; 1 with an expected difference: five amount fields that the original fills from storage the read never filled Java on PostgreSQL The same Each of the four fix switches Expected differences only, each one explained by its switch Mutation check One cent changed in one recorded screen: the comparison fails, as it should Findings: an account with more than one card cannot be opened (the DUPKEY answer is treated as a file error); a card with no account record still shows its customer; messages carry internal codes and are cut off at 75 characters; amounts of a billion or more lose their first digit on screen; the full SSN, government id and bank account are on screen. Each has a switch, off by default. The account screen in the Parity Lab runs both sides for any account number typed.
- Translation: a script replaces each of the program's 15
-
Bring across a screen that writes
The Update Account screen (transaction CAUP, program COACTUPC, map COACTUP, 4,236 lines) reads an account, its card and its customer, checks every changed field, asks for F5, reads both records again for update, checks that they have not changed since they were shown, and rewrites both, undoing the first if the second fails. The view screen's harness was extended, not copied:
- More CICS: READ UPDATE, REWRITE and SYNCPOINT (also with ROLLBACK). The rewrite stand-in keeps a before-image of each record it changes; ROLLBACK writes them back. A task that ends without a syncpoint keeps its changes, as CICS does. A test switch can make one rewrite answer INVREQ, to exercise the rollback path.
- A Language Environment service: the program's date check (CardDemo's CSUTLDTC) calls CEEDAYS. A stand-in validates the date for the one picture CardDemo uses, YYYYMMDD, and returns the documented feedback codes.
- A stricter terminal: typing into a protected field stops the scenario, because a 3270 keyboard would lock. This matters: the program does not check fields again on F5, it relies on the terminal protecting them. A web API cannot rely on that, so the Java service checks again on every save.
- Another user: a step can change or delete a record before a key press, to test "changed by someone else" and failed locks.
- The data afterwards: after the last step the indexed files are written out as fixed-width text and every changed record is kept, byte for byte, binary zeros included.
python3 scenarios/make_scenarios.py # 20 scenarios sh build.sh build # COACTUPC + CSUTLDTC + CEEDAYS and CICS stand-ins python3 run_legacy.py build scenarios/scenarios.json golden # 389 screens, records kept sh numval_check.sh classes 20000 # Java vs GnuCOBOL money parsing, 40,042 inputs sh parity_run.sh --db jdbc:postgresql://localhost:5432/carddemoThe rules were carried over one by one, in the program's order and with its messages, onto fixed-width fields as the program holds them. Two pieces needed the compiler's exact behavior. Money fields are read with
TEST-NUMVAL-CandNUMVAL-C; the Java follows GnuCOBOL's routine step for step (it accepts3-9as −39, and does not see a CR in the last two positions of the field), checked on 40,042 generated inputs with no disagreement. And a field of digits that holds a blank equals no number and satisfies no range, as in GnuCOBOL; IBM's compiler may read1as 10, so a one-digit month is a case to check on a mainframe.Saving is one unit of work. On the record files a rollback puts the old records back; on PostgreSQL it is one transaction with
SELECT … FOR UPDATEon both rows. PostgreSQL text cannot hold the binary zeros the program writes into cleared fields, so the database stores the Unicode "symbol for null" in their place and gives the zeros back when the rows are exported for the comparison.Run Result Java on record files 19 of 20 scenarios identical, screens and saved records; 1 with an expected difference: a one-digit day, which the original saves as 2014-11-5and the Java's data model refusesJava on PostgreSQL The same Each of the seven fix switches Expected differences only, each starting at a step where the Java says which switch changed it Mutation check FICO limit 851 instead of 850 in the Java: the comparison fails, as it should Unit tests 16 new, on the rules, the money parsing and the saves Two of the findings came from reading the results, not the code. All three parts of the SSN blank showed only the first one in error: the first part's IF has no END-IF, and the paragraph's full stop closes it, so the other two parts are checked only inside it. And a crash at the end of every save turned out to be the stand-in's own counter, an EXTERNAL item that starts as blanks: one more case for initializing storage explicitly. The account update in the Parity Lab runs both sides on fresh copies of the data.
4 · Problems on the way
What went wrong, and what it taught
Most of these would have passed unnoticed without an independent check or a byte-for-byte comparison.
| Problem | How it showed | Resolution |
|---|---|---|
| Mainframe sign convention | A plausible but wrong first reference run; two compiler versions disagreeing | -fsign=EBCDIC; independent model on every run |
| Unset storage | Record fillers different between compiler versions | -fdefaultbyte=32 |
| Low-values in text output | A file status 71 error writing line-sequential output on GnuCOBOL 3.2 | Dump as fixed-length records, convert to text afterwards |
| Library warnings in program output | A system message mixed into the printed output on one machine | Program output and error output captured separately |
| Real clock in outputs | Every run different | Clock pinned; fractions of a second masked and documented |
| Report dates older than the pinned date | The job's own dates would filter out every transaction | Report on the run date; documented |
| Sort order not fully defined | The report's sort has no stable-order option, so order within a card can vary on z/OS | Stable sort in both versions; documented as a limit |
| Inconsistent test data of our own | The new codec refused a hand-made record with a blank filler where CardDemo uses zeros | Rule settled: fillers may be blank or zeros, anything else is refused |
| Duplicate ids across test cases | The database's primary key refused two edge-case files that shared transaction ids; the file version had silently accepted them | Edge cases given distinct ids; references regenerated |
| A counter that started as blanks | Every save crashed the COBOL run at the end, after the screen was sent | The rollback stand-in's EXTERNAL counter initialized explicitly |
| An IF closed by a full stop | Two of three SSN parts were not flagged, and the Java flagged them | Found from the screens; the Java now follows the program's real structure, and the finding is reported |
| Scenarios a 3270 could not type | A scenario typed into a protected field and the program happily read it | The terminal stand-in refuses typing into protected fields |
| A CICS program with no CICS | The online screen could not run outside the mainframe | Its CICS commands translated into calls to stand-ins; assumptions listed and testable |
| Container registries unreachable in the build environment | The container image could not be built locally | Everything inside it tested locally; the image built and self-tested in CI on every pull request |
5 · Limits
What this does not prove
- GnuCOBOL is not z/OS. The references come from GnuCOBOL on Linux. Two compiler versions agree byte for byte, but a run on z/OS or AWS Mainframe Modernization would close the gap. Program output differs in one known detail: GnuCOBOL prints signed numbers with a trailing sign.
- ASCII data. CardDemo also ships its data in mainframe encoding (EBCDIC), where sort order differs. Not yet run.
- Sort and copy utilities are stand-ins, written from the JCL.
- Some failure paths of the original programs (a missing account or rate, a part-way update) need broken files and are not part of the comparisons.
- Volumes. CardDemo's data is small. The database versions read whole tables into memory; production volumes would page through them.
- Three batch slices and two screens. Statements and 15 more online programs are still ahead. The CICS stand-ins cover only the commands the two account screens use, and locking is tested by changing data between key presses, not by two users at the same moment.
Want this kind of evidence for your system?
A two-to-three-week assessment maps your system, finds the risks and gives you a fixed price for the first slice.