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

Built with AI assistance; see how. Record as of October 2026. CardDemo source: aws-samples/aws-mainframe-modernization-carddemo, commit 59cc6c2, Apache 2.0.

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.

PhaseWhat was doneResult
InventoryRead 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 runsRan 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 checkRe-implemented each job's rules in Python straight from the COBOL and compared with every reference run.Caught a wrong first reference run.
Edge casesCrafted 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.
RewriteJava 21 ports that read and write the same records, quirks included; fixes behind switches, off by default.Identical on every test case.
FindingsEvery behavior a rewrite could "fix" by accident written up, with the exact records a fix would change.8 findings for the business.
DatabaseTyped data model, lossless migration to PostgreSQL, jobs on the database, all-or-nothing saves.19 of 19 checks identical.
On AWSThe whole chain, COBOL and Java side by side, on serverless AWS, deployed from GitHub.Public at legacyparity.com/lab.
First online screenThe 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 writesThe 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

ToolVersionUsed for
GnuCOBOL3.1.2 and 3.2Compiling and running the unchanged COBOL programs on Linux
Python3The test harness: input preparation, the sort-step stand-in, summaries, comparisons and the independent models
Java21 (OpenJDK)The rewritten jobs, record-level and on the data model
Maven, JUnit3, 5Builds and unit tests
PostgreSQL, JDBC driver16, 42.7The database step, locally and on Amazon Aurora
Ubuntu24.04 (WSL locally)Where everything runs, and the base of the AWS container image
DockerThe container image that runs both COBOL and Java on AWS Lambda
AWS CDK2 (TypeScript)All AWS infrastructure, as code
GitHub ActionsChecks on every pull request, deploy on merge; AWS access through OpenID Connect, no stored keys
AI coding assistantDrafting code, scripts, tests and documentation, reviewed and verified by running everything against the original

AWS services in the Parity Lab

ServiceRole
Amazon CloudFront, S3, Route 53, ACMServe the site and route /api to the API, over HTTPS
Amazon API GatewayThe lab's API, with per-route request limits
AWS LambdaA 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 FunctionsRuns the batch in order: each job's COBOL and Java in parallel, compare, next job, report; cleanup on failure
Amazon S3Run 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 ManagerOne-time database setup without opening network access to the database; the runner signs in with IAM tokens, no stored password
Amazon VPCPrivate subnets with no internet access and no NAT gateway; S3 through a free gateway endpoint
Amazon ECR, CloudWatch LogsThe container image; logs kept a month
AWS BudgetsAn email alert at $8 of actual or $10 of forecast monthly charges

3 · Step by step

What was done, in order

  1. 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.

  2. 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_ACCTFILE and so on).
    • Pins the clock with COB_CURRENT_DATE to 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.

  3. 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 ({ and A–I are +0 to +9, } and J–R are −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=EBCDIC both versions agree: 262 posted, 38 rejected.

    -fdefaultbyte=32 pins 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.

  4. 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.

  5. 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.
  6. 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.

  7. 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/
    JobTest dataSizeResult
    PostingCardDemo sample / edge cases300 / 309 transactionsIdentical / Identical
    InterestAfter posting / edge cases50 / 58 interest chargesIdentical / Identical
    ReportNightly data / edge cases / dates out of step519 / 540 / 537 linesIdentical in all three
  8. 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.

    JobFindingEffect on CardDemo's dataFix switch
    PostingRefunds count as extra spending in the credit-limit check17 of 38 rejections happen only because of it; the fix changes 19 decisionsyes
    InterestThe last account in the file never gets its interest$18.77 charged but never added; cycle totals not resetyes
    InterestInterest is cut off at the cent, not rounded28 of 50 charges a cent short, $0.28 in totalyes
    ReportThe last transaction is counted twice in the totalsGrand total $79,254.29 instead of $79,233.86yes
    ReportThe last card's total is never printed49 card totals for 50 cardsyes (with the above)
    ReportPages run on past 20 lines8 of 18 pages are 40 or 42 linesyes
    Report"Account Total" is a card totalAn account with two cards gets two totalsno: business decision
    ReportDates out of step end the report silentlyNo page or grand total, return code still 0yes
  9. 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.
  10. 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.
  11. 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 CICS commands 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/carddemo

    The 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.

    RunResult
    Java on record files10 of 11 scenarios identical; 1 with an expected difference: five amount fields that the original fills from storage the read never filled
    Java on PostgreSQLThe same
    Each of the four fix switchesExpected differences only, each one explained by its switch
    Mutation checkOne 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.

  12. 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/carddemo

    The 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-C and NUMVAL-C; the Java follows GnuCOBOL's routine step for step (it accepts 3-9 as −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 read 1 as 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 UPDATE on 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.

    RunResult
    Java on record files19 of 20 scenarios identical, screens and saved records; 1 with an expected difference: a one-digit day, which the original saves as 2014-11-5 and the Java's data model refuses
    Java on PostgreSQLThe same
    Each of the seven fix switchesExpected differences only, each starting at a step where the Java says which switch changed it
    Mutation checkFICO limit 851 instead of 850 in the Java: the comparison fails, as it should
    Unit tests16 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.

ProblemHow it showedResolution
Mainframe sign conventionA plausible but wrong first reference run; two compiler versions disagreeing-fsign=EBCDIC; independent model on every run
Unset storageRecord fillers different between compiler versions-fdefaultbyte=32
Low-values in text outputA file status 71 error writing line-sequential output on GnuCOBOL 3.2Dump as fixed-length records, convert to text afterwards
Library warnings in program outputA system message mixed into the printed output on one machineProgram output and error output captured separately
Real clock in outputsEvery run differentClock pinned; fractions of a second masked and documented
Report dates older than the pinned dateThe job's own dates would filter out every transactionReport on the run date; documented
Sort order not fully definedThe report's sort has no stable-order option, so order within a card can vary on z/OSStable sort in both versions; documented as a limit
Inconsistent test data of our ownThe new codec refused a hand-made record with a blank filler where CardDemo uses zerosRule settled: fillers may be blank or zeros, anything else is refused
Duplicate ids across test casesThe database's primary key refused two edge-case files that shared transaction ids; the file version had silently accepted themEdge cases given distinct ids; references regenerated
A counter that started as blanksEvery save crashed the COBOL run at the end, after the screen was sentThe rollback stand-in's EXTERNAL counter initialized explicitly
An IF closed by a full stopTwo of three SSN parts were not flagged, and the Java flagged themFound from the screens; the Java now follows the program's real structure, and the finding is reported
Scenarios a 3270 could not typeA scenario typed into a protected field and the program happily read itThe terminal stand-in refuses typing into protected fields
A CICS program with no CICSThe online screen could not run outside the mainframeIts CICS commands translated into calls to stand-ins; assumptions listed and testable
Container registries unreachable in the build environmentThe container image could not be built locallyEverything 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.

Read the case study · Run it in the Parity Lab · All demos

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.

Book an assessment call