V2 preview See the accepted version

IBM Bob for Ingram SI and reseller partnersIBM Bob for Ingram partners

IBM Bob turned a legacy application's code into the four documents an SI prices from.

Follow one line of code, Desk.java line 93, through every stage to an illustrative SGD 2,940: synthetic, not a quote.

Sizing a modernisation is slow, expert work, and a customer's private code can't simply be handed to a public AI tool.

The app we started with Its code, how it starts, and a recorded copy of its screen to try
  • On a small sample application: 18 files, 674 lines
  • In our controlled runs, all four commands cost about 9 to 10 Bob credits (Bobcoins), about US$4.50 to US$5 at IBM's published rate. That is IBM's usage charge for running Bob, not part of the SI's price to the client.
  • A person on our side checked the assessment pack and accepted it for a pilot; production acceptance is still pending

Part 1 · The story

The map

Four people, four stages, one assessment

Each box is a job Bob does and links to its evidence. The orange boxes follow one finding from a line of code to a price.

Customer ITgrants repo access
Discover
Ingest repos securely Map dependencies and call graphs Document legacy code
Hands overthe evidence
Delivery leadowns the plan and team
Plan
Break into work packages Size effort per module Map skills to team
Hands overthe plan and effort model
Commercial leadprices the deal
Prove and price
Refactor a pilot slice Calibrate velocity Price SOW and risk buffer
Hands overthe SOW, priced by the SI

01

Customer IT · grants repo access

Discover: what's there, and what Bob may see

Your customer's code stays in a fenced copy, and every line of Bob's evidence points back to the source.

Ingest repos securely

Bob only sees the copy you approve, and he can't change it.

Went in

18 files, 674 lines

of application code, copied and fixed before Bob starts. A small sample application; a real customer estate is many times larger.

Kept out

  • Secrets: .env files, secrets folders, credential files, certificates and keys
  • The git history
  • Build output, logs and local databases
  • Earlier runs and internal material
  1. The SI writes the list of what is kept out before Bob starts.
  2. This demo used Bob SaaS, IBM's hosted service; a self-hosted or on-premises Bob is not shown. Check IBM's terms before sending real code.

Map dependencies and call graphs

Bob maps what calls what, and every call is checked against the code.

Calls between classes Web requests Database Config

In all, 43 links: 23 of them calls between classes, 13 web requests, 5 database queries and 2 configuration links. Bar widths to scale.

  1. 23 of 23 checked against source; the web, database and configuration links are as Bob read them.
  2. Bob says plainly what reading can't tell you: Not runtime-observed; inferred from source.

Document legacy code

For line 93, Bob writes down exactly what he saw.

93String sql = "SELECT * FROM customers WHERE LOWER(name) LIKE '%" + (q == null ? "" : q.toLowerCase()) + "%' ORDER BY id";

customers() builds a SQL query string by directly interpolating q.toLowerCase() into a LIKE clause without parameterization … This is a SQL injection risk; the query is not parameterized.

Bob, evidence EV-007, Discover

In plain words: whatever someone types in the customer search box is glued straight into the database query.

  1. This is the line we follow. In the next chapter it becomes a finding, then a work package, then a price.
Watch Bob do it: his Discover session, in four moments

Our commentary is on the left; the dark panels are what Bob read, said and wrote.

  1. 1

    Bob reads the whole application first. Before writing anything he opens every source file, including the web layer that shows which request calls which piece of logic.

    Now I'll read all ten source files in parallel:

    read file app/src/main/java/com/ingramtrials/base/DeskController.java

    20 | @RequestMapping("/api")
    33 |     @GetMapping("/customers")
    34 |     public List<Map<String,Object>> customers(@RequestParam(defaultValue="") String q) { return desk.customers(q); }
    37 |     @GetMapping("/invoices")
    38 |     public List<Map<String,Object>> invoices(@RequestParam(defaultValue="") String q) { return desk.invoices(q); }
    All 64 lines
    1 | package com.ingramtrials.base;
    2 | 
    3 | import java.util.List;
    4 | import java.util.Map;
    5 | 
    6 | import org.springframework.beans.factory.annotation.Value;
    7 | import org.springframework.http.HttpStatus;
    8 | import org.springframework.web.bind.annotation.ExceptionHandler;
    9 | import org.springframework.web.bind.annotation.GetMapping;
    10 | import org.springframework.web.bind.annotation.PathVariable;
    11 | import org.springframework.web.bind.annotation.PostMapping;
    12 | import org.springframework.web.bind.annotation.RequestBody;
    13 | import org.springframework.web.bind.annotation.RequestMapping;
    14 | import org.springframework.web.bind.annotation.RequestParam;
    15 | import org.springframework.web.bind.annotation.ResponseStatus;
    16 | import org.springframework.web.bind.annotation.RestController;
    17 | import org.springframework.web.server.ResponseStatusException;
    18 | 
    19 | @RestController
    20 | @RequestMapping("/api")
    21 | public class DeskController {
    22 |     private final Desk desk;
    23 |     @Value("${spring.datasource.url}") String database;
    24 |     DeskController(Desk desk) { this.desk=desk; }
    25 | 
    26 |     @GetMapping("/health")
    27 |     public Map<String,Object> health() {
    28 |         Integer ping=desk.db.queryForObject("SELECT 1",Integer.class);
    29 |         if (ping==null || ping!=1) throw new IllegalStateException("Database health check failed");
    30 |         return Desk.map("status","UP","case","BASE","javaVersion",System.getProperty("java.version"),
    31 |             "businessDate",desk.today.toString(),"database",database);
    32 |     }
    33 |     @GetMapping("/customers")
    34 |     public List<Map<String,Object>> customers(@RequestParam(defaultValue="") String q) { return desk.customers(q); }
    35 |     @GetMapping("/products")
    36 |     public List<Map<String,Object>> products() { return desk.products(); }
    37 |     @GetMapping("/invoices")
    38 |     public List<Map<String,Object>> invoices(@RequestParam(defaultValue="") String q) { return desk.invoices(q); }
    39 |     @GetMapping("/invoices/{id}")
    40 |     public Map<String,Object> invoice(@PathVariable int id) { return desk.invoice(id); }
    41 |     @PostMapping("/invoices/preview")
    42 |     public Map<String,Object> preview(@RequestBody Map<String,Object> body) { return desk.calculate(body,true); }
    43 |     @PostMapping("/invoices")
    44 |     @ResponseStatus(HttpStatus.CREATED)
    45 |     public Map<String,Object> save(@RequestBody Map<String,Object> body) { return desk.save(body); }
    46 |     @GetMapping("/credit-requests")
    47 |     public List<Map<String,Object>> creditRequests() { return desk.creditRequests(); }
    48 |     @PostMapping("/credit-requests")
    49 |     @ResponseStatus(HttpStatus.CREATED)
    50 |     public Map<String,Object> requestCredit(@RequestBody Map<String,Object> body) { return desk.requestCredit(body); }
    51 |     @GetMapping("/credit-notes")
    52 |     public List<Map<String,Object>> creditNotes() { return desk.creditNotes(); }
    53 |     @GetMapping("/orders")
    54 |     public List<Map<String,Object>> orders() { return desk.orders(); }
    55 |     @PostMapping("/jobs/invoices")
    56 |     public Map<String,Object> invoicesJob() throws Exception { return desk.runJob("invoices"); }
    57 |     @PostMapping("/jobs/credit-notes")
    58 |     public Map<String,Object> creditsJob() throws Exception { return desk.runJob("credit-notes"); }
    59 |     @GetMapping("/reports/monthly")
    60 |     public Map<String,Object> monthly(@RequestParam String month) { return desk.monthly(month); }
    61 |     @GetMapping("/reports/gst")
    62 |     public Map<String,Object> gst(@RequestParam int year) { return desk.annual(year); }
    63 | }
    64 | 
  2. 2

    Bob sees the search box feeding the database directly. In the customer and invoice searches, whatever the user types is glued straight into the database query, which is the defect we follow through every chapter.

    read file app/src/main/java/com/ingramtrials/base/Desk.java

    92 |     public List<Map<String,Object>> customers(String q) {
    93 |         String sql = "SELECT * FROM customers WHERE LOWER(name) LIKE '%" + (q == null ? "" : q.toLowerCase()) + "%' ORDER BY id";
    129 |     public List<Map<String,Object>> invoices(String q) {
    130 |         String sql = INVOICE_SELECT + " WHERE LOWER(c.name) LIKE '%" + (q == null ? "" : q.toLowerCase()) +
    131 |             "%' OR LOWER(i.invoice_no) LIKE '%" + (q == null ? "" : q.toLowerCase()) + "%' ORDER BY i.id DESC";
    All 293 lines
    1 | package com.ingramtrials.base;
    2 | 
    3 | import java.io.InputStream;
    4 | import java.math.BigDecimal;
    5 | import java.math.RoundingMode;
    6 | import java.sql.ResultSet;
    7 | import java.sql.SQLException;
    8 | import java.time.LocalDate;
    9 | import java.util.ArrayList;
    10 | import java.util.Arrays;
    11 | import java.util.LinkedHashMap;
    12 | import java.util.List;
    13 | import java.util.Map;
    14 | import java.util.Properties;
    15 | 
    16 | import javax.annotation.PostConstruct;
    17 | 
    18 | import org.springframework.beans.factory.annotation.Value;
    19 | import org.springframework.http.HttpStatus;
    20 | import org.springframework.jdbc.core.JdbcTemplate;
    21 | import org.springframework.stereotype.Component;
    22 | import org.springframework.web.server.ResponseStatusException;
    23 | 
    24 | @Component
    25 | public class Desk {
    26 |     final JdbcTemplate db;
    27 |     final LocalDate today;
    28 |     private final Properties jobs = new Properties();
    29 |     static final BigDecimal GST = new BigDecimal("0.09");
    30 | 
    31 |     Desk(JdbcTemplate db, @Value("${demo.today:2026-10-13}") String date) {
    32 |         this.db = db;
    33 |         this.today = LocalDate.parse(date);
    34 |     }
    35 | 
    36 |     @PostConstruct
    37 |     void setup() throws Exception {
    38 |         for (String ddl : Arrays.asList(
    39 |             "CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY, name VARCHAR(120), tier CHAR(1), email VARCHAR(160))",
    40 |             "CREATE TABLE IF NOT EXISTS products (code VARCHAR(40) PRIMARY KEY, description VARCHAR(160), unit_price DECIMAL(18,4), category VARCHAR(80))",
    41 |             "CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer_id INTEGER, status VARCHAR(20), delivered_date DATE, invoice_id INTEGER)",
    42 |             "CREATE TABLE IF NOT EXISTS order_lines (order_id INTEGER, line_no INTEGER, product_code VARCHAR(40), qty INTEGER, unit_price DECIMAL(18,4), PRIMARY KEY(order_id,line_no))",
    43 |             "CREATE TABLE IF NOT EXISTS invoices (id INTEGER PRIMARY KEY, invoice_no VARCHAR(40) UNIQUE, customer_id INTEGER, order_id INTEGER UNIQUE, invoice_date DATE, subtotal DECIMAL(18,2), gst DECIMAL(18,2), total DECIMAL(18,2))",
    44 |             "CREATE TABLE IF NOT EXISTS invoice_lines (invoice_id INTEGER, line_no INTEGER, product_code VARCHAR(40), qty INTEGER, unit_price DECIMAL(18,4), line_net DECIMAL(18,2), PRIMARY KEY(invoice_id,line_no))",
    45 |             "CREATE TABLE IF NOT EXISTS credit_requests (id INTEGER PRIMARY KEY, invoice_id INTEGER, amount_net DECIMAL(18,2), reason VARCHAR(500), status VARCHAR(20), credit_note_id INTEGER)",
    46 |             "CREATE TABLE IF NOT EXISTS credit_notes (id INTEGER PRIMARY KEY, credit_note_no VARCHAR(40) UNIQUE, invoice_id INTEGER, credit_date DATE, amount_net DECIMAL(18,2), gst DECIMAL(18,2), total DECIMAL(18,2), reason VARCHAR(500))"
    47 |         )) db.execute(ddl);
    48 |         if (db.queryForObject("SELECT COUNT(*) FROM customers", Integer.class) == 0) {
    49 |             db.update("INSERT INTO customers VALUES (1,'Aster Office Supplies','A','orders@aster.example'),(2,'Merlion Retail','B','finance@merlion.example'),(3,'Straits Workshop','C','accounts@straits.example')");
    50 |             db.update("INSERT INTO products VALUES ('PAPER-A4','A4 paper carton',10.0000,'Office'),('TONER-BK','Black toner cartridge',50.0000,'Print'),('CABLE-USB','USB cable pack',3.3350,'Accessories')");
    51 |             db.update("INSERT INTO invoices VALUES (1001,'INV-2026-00001',2,NULL,DATE '2026-10-12',485.00,43.65,528.65)");
    52 |             db.update("INSERT INTO invoice_lines VALUES (1001,1,'TONER-BK',10,50.0000,485.00)");
    53 |             db.update("INSERT INTO orders VALUES (7001,1,'DELIVERED',DATE '2026-10-12',NULL),(7002,3,'PENDING',NULL,NULL)");
    54 |             db.update("INSERT INTO order_lines VALUES (7001,1,'PAPER-A4',101,10.0000),(7002,1,'TONER-BK',1,50.0000)");
    55 |         }
    56 |         try (InputStream in = getClass().getResourceAsStream("/jobs.properties")) {
    57 |             if (in == null) throw new IllegalStateException("Missing jobs.properties");
    58 |             jobs.load(in);
    59 |         }
    60 |     }
    61 | 
    62 |     static Map<String,Object> map(Object... pairs) {
    63 |         Map<String,Object> result = new LinkedHashMap<>();
    64 |         for (int i = 0; i < pairs.length; i += 2) result.put((String)pairs[i], pairs[i+1]);
    65 |         return result;
    66 |     }
    67 |     static String money(BigDecimal n) { return n.setScale(2, RoundingMode.HALF_UP).toPlainString(); }
    68 |     static String unit(BigDecimal n) {
    69 |         return n.setScale(n.stripTrailingZeros().scale()>2 ? 4 : 2, RoundingMode.UNNECESSARY).toPlainString();
    70 |     }
    71 |     static BigDecimal decimal(Object value, String field) {
    72 |         try {
    73 |             if (value == null) throw new NumberFormatException();
    74 |             return new BigDecimal(value.toString());
    75 |         } catch (NumberFormatException ex) {
    76 |             throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " must be decimal");
    77 |         }
    78 |     }
    79 |     static int integer(Object value, String field) {
    80 |         try { return Integer.parseInt(value.toString()); }
    81 |         catch (Exception ex) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " must be integer"); }
    82 |     }
    83 |     static String required(Object value, String field) {
    84 |         if (!(value instanceof String) || ((String)value).trim().isEmpty())
    85 |             throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required");
    86 |         return ((String)value).trim();
    87 |     }
    88 |     static ResponseStatusException missing(String what) {
    89 |         return new ResponseStatusException(HttpStatus.NOT_FOUND, "Unknown " + what);
    90 |     }
    91 | 
    92 |     public List<Map<String,Object>> customers(String q) {
    93 |         String sql = "SELECT * FROM customers WHERE LOWER(name) LIKE '%" + (q == null ? "" : q.toLowerCase()) + "%' ORDER BY id";
    94 |         return db.query(sql, (rs,n) -> map("id",rs.getInt("id"),"name",rs.getString("name"),"tier",rs.getString("tier"),"email",rs.getString("email")));
    95 |     }
    96 |     public List<Map<String,Object>> products() {
    97 |         return db.query("SELECT * FROM products ORDER BY code", (rs,n) -> map("code",rs.getString("code"),"description",rs.getString("description"),"unitPrice",unit(rs.getBigDecimal("unit_price")),"category",rs.getString("category")));
    98 |     }
    99 |     Map<String,Object> customer(int id) {
    100 |         List<Map<String,Object>> found = db.query("SELECT * FROM customers WHERE id=?", (rs,n) -> map("id",rs.getInt("id"),"name",rs.getString("name"),"tier",rs.getString("tier"),"email",rs.getString("email")), id);
    101 |         if (found.isEmpty()) throw missing("customer");
    102 |         return found.get(0);
    103 |     }
    104 |     void product(String code) {
    105 |         if (db.queryForObject("SELECT COUNT(*) FROM products WHERE code=?", Integer.class,code)==0) throw missing("product");
    106 |     }
    107 |     BigDecimal previewLineNet(String tier, int qty, BigDecimal price) {
    108 |         BigDecimal net=price.multiply(BigDecimal.valueOf(qty));
    109 |         if ("A".equals(tier)) net=net.multiply(new BigDecimal("0.95"));
    110 |         if ("B".equals(tier)) net=net.multiply(new BigDecimal("0.97"));
    111 |         if (qty>=100) net=net.multiply(new BigDecimal("0.98"));
    112 |         return net.setScale(2,RoundingMode.HALF_UP);
    113 |     }
    114 |     BigDecimal bookedLineNet(String tier, int qty, BigDecimal price) {
    115 |         BigDecimal net=price.multiply(BigDecimal.valueOf(qty));
    116 |         if ("A".equals(tier)) net=net.multiply(new BigDecimal("0.95"));
    117 |         if ("B".equals(tier)) net=net.multiply(new BigDecimal("0.97"));
    118 |         if (qty>100) net=net.multiply(new BigDecimal("0.98"));
    119 |         return net.setScale(2,RoundingMode.HALF_UP);
    120 |     }
    121 |     Map<String,Object> invoiceRow(ResultSet rs) throws SQLException {
    122 |         Object orderId = rs.getObject("order_id");
    123 |         return map("id",rs.getInt("id"),"invoiceNo",rs.getString("invoice_no"),"customerId",rs.getInt("customer_id"),
    124 |             "customerName",rs.getString("customer_name"),"invoiceDate",rs.getDate("invoice_date").toString(),
    125 |             "subtotal",money(rs.getBigDecimal("subtotal")),"gst",money(rs.getBigDecimal("gst")),
    126 |             "total",money(rs.getBigDecimal("total")),"orderId",orderId);
    127 |     }
    128 |     static final String INVOICE_SELECT = "SELECT i.*, c.name AS customer_name FROM invoices i JOIN customers c ON c.id=i.customer_id";
    129 |     public List<Map<String,Object>> invoices(String q) {
    130 |         String sql = INVOICE_SELECT + " WHERE LOWER(c.name) LIKE '%" + (q == null ? "" : q.toLowerCase()) +
    131 |             "%' OR LOWER(i.invoice_no) LIKE '%" + (q == null ? "" : q.toLowerCase()) + "%' ORDER BY i.id DESC";
    132 |         return db.query(sql, (rs,n) -> invoiceRow(rs));
    133 |     }
    134 |     public Map<String,Object> invoice(int id) {
    135 |         List<Map<String,Object>> found = db.query(INVOICE_SELECT + " WHERE i.id=?", (rs,n) -> invoiceRow(rs), id);
    136 |         if (found.isEmpty()) throw missing("invoice");
    137 |         Map<String,Object> result = found.get(0);
    138 |         result.put("lines", db.query("SELECT * FROM invoice_lines WHERE invoice_id=? ORDER BY line_no",
    139 |             (rs,n) -> map("lineNo",rs.getInt("line_no"),"productCode",rs.getString("product_code"),
    140 |                 "qty",rs.getInt("qty"),"unitPrice",rs.getBigDecimal("unit_price").setScale(4).toPlainString(),
    141 |                 "lineNet",money(rs.getBigDecimal("line_net"))), id));
    142 |         return result;
    143 |     }
    144 | 
    145 |     @SuppressWarnings("unchecked")
    146 |     public Map<String,Object> calculate(Map<String,Object> input, boolean preview) {
    147 |         if (input == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Body is required");
    148 |         int customerId = integer(input.get("customerId"),"customerId");
    149 |         Map<String,Object> customer = customer(customerId);
    150 |         Object raw = input.get("lines");
    151 |         if (!(raw instanceof List)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"lines must be an array");
    152 |         List<?> lines = (List<?>)raw;
    153 |         if (lines.size()<1 || lines.size()>50) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"lines must contain 1 to 50 entries");
    154 |         List<Map<String,Object>> priced = new ArrayList<>();
    155 |         BigDecimal subtotal = BigDecimal.ZERO;
    156 |         int number = 0;
    157 |         for (Object item : lines) {
    158 |             if (!(item instanceof Map)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"line must be an object");
    159 |             Map<String,Object> line = (Map<String,Object>)item;
    160 |             String code = required(line.get("productCode"),"productCode");
    161 |             product(code);
    162 |             int qty = integer(line.get("qty"),"qty");
    163 |             if (qty<1 || qty>10000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"qty must be 1 to 10000");
    164 |             BigDecimal price = decimal(line.get("unitPrice"),"unitPrice");
    165 |             if (price.compareTo(BigDecimal.ZERO)<=0 || price.compareTo(new BigDecimal("100000"))>0 || price.scale()>4)
    166 |                 throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"unitPrice is out of range");
    167 |             String tier = (String)customer.get("tier");
    168 |             BigDecimal net = preview ? previewLineNet(tier,qty,price) : bookedLineNet(tier,qty,price);
    169 |             subtotal = subtotal.add(net);
    170 |             priced.add(map("lineNo",++number,"productCode",code,"qty",qty,
    171 |                 "unitPrice",price.setScale(4).toPlainString(),"lineNet",money(net)));
    172 |         }
    173 |         BigDecimal gst = subtotal.multiply(GST).setScale(2,RoundingMode.HALF_UP);
    174 |         return map("customerId",customerId,"customerName",customer.get("name"),"tier",customer.get("tier"),
    175 |             "subtotal",money(subtotal),"gst",money(gst),"total",money(subtotal.add(gst)),"lines",priced);
    176 |     }
    177 |     public synchronized Map<String,Object> save(Map<String,Object> input) {
    178 |         Map<String,Object> priced = calculate(input,false);
    179 |         int id = db.queryForObject("SELECT COALESCE(MAX(id),1000)+1 FROM invoices",Integer.class);
    180 |         String number = String.format("INV-%d-%05d",today.getYear(),id-1000);
    181 |         db.update("INSERT INTO invoices VALUES (?,?,?,?,?,?,?,?)",id,number,priced.get("customerId"),null,today,
    182 |             priced.get("subtotal"),priced.get("gst"),priced.get("total"));
    183 |         for (Map<String,Object> line : (List<Map<String,Object>>)priced.get("lines"))
    184 |             db.update("INSERT INTO invoice_lines VALUES (?,?,?,?,?,?)",id,line.get("lineNo"),line.get("productCode"),
    185 |                 line.get("qty"),line.get("unitPrice"),line.get("lineNet"));
    186 |         return invoice(id);
    187 |     }
    188 | 
    189 |     public List<Map<String,Object>> creditRequests() {
    190 |         return db.query("SELECT * FROM credit_requests ORDER BY id", (rs,n) -> map("id",rs.getInt("id"),
    191 |             "invoiceId",rs.getInt("invoice_id"),"amountNet",money(rs.getBigDecimal("amount_net")),
    192 |             "reason",rs.getString("reason"),"status",rs.getString("status"),"creditNoteId",rs.getObject("credit_note_id")));
    193 |     }
    194 |     public synchronized Map<String,Object> requestCredit(Map<String,Object> input) {
    195 |         if (input==null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Body is required");
    196 |         int invoiceId = integer(input.get("invoiceId"),"invoiceId");
    197 |         Map<String,Object> inv = invoice(invoiceId);
    198 |         BigDecimal amount = decimal(input.get("amountNet"),"amountNet");
    199 |         if (amount.scale()>2 || amount.compareTo(BigDecimal.ZERO)<=0 ||
    200 |             amount.compareTo(new BigDecimal((String)inv.get("subtotal")))>0)
    201 |             throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"amountNet must be positive and at most invoice subtotal");
    202 |         String reason = required(input.get("reason"),"reason");
    203 |         if (reason.length()>500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"reason is too long");
    204 |         int id = db.queryForObject("SELECT COALESCE(MAX(id),0)+1 FROM credit_requests",Integer.class);
    205 |         db.update("INSERT INTO credit_requests VALUES (?,?,?,?,?,?)",id,invoiceId,amount,reason,"APPROVED",null);
    206 |         return map("id",id,"invoiceId",invoiceId,"amountNet",money(amount),"reason",reason,"status","APPROVED","creditNoteId",null);
    207 |     }
    208 |     public List<Map<String,Object>> creditNotes() {
    209 |         return db.query("SELECT * FROM credit_notes ORDER BY id", (rs,n) -> map("id",rs.getInt("id"),
    210 |             "creditNoteNo",rs.getString("credit_note_no"),"invoiceId",rs.getInt("invoice_id"),
    211 |             "creditDate",rs.getDate("credit_date").toString(),"amountNet",money(rs.getBigDecimal("amount_net")),
    212 |             "gst",money(rs.getBigDecimal("gst")),"total",money(rs.getBigDecimal("total")),"reason",rs.getString("reason")));
    213 |     }
    214 |     public List<Map<String,Object>> orders() {
    215 |         return db.query("SELECT * FROM orders ORDER BY id", (rs,n) -> map("id",rs.getInt("id"),
    216 |             "customerId",rs.getInt("customer_id"),"status",rs.getString("status"),
    217 |             "deliveredDate",rs.getDate("delivered_date")==null?null:rs.getDate("delivered_date").toString(),
    218 |             "invoiceId",rs.getObject("invoice_id")));
    219 |     }
    220 |     public synchronized Map<String,Object> runJob(String name) throws Exception {
    221 |         String className = jobs.getProperty(name);
    222 |         if (className == null) throw missing("job");
    223 |         Job job = (Job)Class.forName(className).newInstance();
    224 |         return map("job",name,"created",job.execute(this));
    225 |     }
    226 |     public int invoiceBatch() {
    227 |         int created=0;
    228 |         List<Map<String,Object>> pending = db.query(
    229 |             "SELECT id,customer_id FROM orders WHERE status='DELIVERED' AND delivered_date=? AND invoice_id IS NULL ORDER BY id",
    230 |             (rs,n)->map("id",rs.getInt(1),"customerId",rs.getInt(2)),today.minusDays(1));
    231 |         for (Map<String,Object> order : pending) {
    232 |             int orderId=(Integer)order.get("id");
    233 |             List<Map<String,Object>> lines=db.query("SELECT product_code,qty,unit_price FROM order_lines WHERE order_id=? ORDER BY line_no",
    234 |                 (rs,n)->map("productCode",rs.getString(1),"qty",rs.getInt(2),"unitPrice",rs.getBigDecimal(3).setScale(4).toPlainString()),orderId);
    235 |             Map<String,Object> priced=save(map("customerId",order.get("customerId"),"lines",lines));
    236 |             db.update("UPDATE invoices SET order_id=? WHERE id=?",orderId,priced.get("id"));
    237 |             db.update("UPDATE orders SET invoice_id=? WHERE id=?",priced.get("id"),orderId);
    238 |             created++;
    239 |         }
    240 |         return created;
    241 |     }
    242 |     public int creditBatch() {
    243 |         int created=0;
    244 |         for (Map<String,Object> req : creditRequests()) {
    245 |             if (!"APPROVED".equals(req.get("status"))) continue;
    246 |             int id=db.queryForObject("SELECT COALESCE(MAX(id),0)+1 FROM credit_notes",Integer.class);
    247 |             BigDecimal net=new BigDecimal((String)req.get("amountNet"));
    248 |             BigDecimal gst=net.multiply(new BigDecimal("0.09")).setScale(2,RoundingMode.HALF_UP);
    249 |             db.update("INSERT INTO credit_notes VALUES (?,?,?,?,?,?,?,?)",id,
    250 |                 String.format("CN-%d-%05d",today.getYear(),id),req.get("invoiceId"),today,net,gst,net.add(gst),req.get("reason"));
    251 |             db.update("UPDATE credit_requests SET status='DONE',credit_note_id=? WHERE id=?",id,req.get("id"));
    252 |             created++;
    253 |         }
    254 |         return created;
    255 |     }
    256 |     public Map<String,Object> monthly(String month) {
    257 |         try { LocalDate.parse(month+"-01"); }
    258 |         catch (Exception ex) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"month must be YYYY-MM"); }
    259 |         List<Map<String,Object>> included=db.query(INVOICE_SELECT+" WHERE FORMATDATETIME(invoice_date,'yyyy-MM')=? ORDER BY i.id",
    260 |             (rs,n)->invoiceRow(rs),month);
    261 |         BigDecimal subtotal=BigDecimal.ZERO,gst=BigDecimal.ZERO,total=BigDecimal.ZERO;
    262 |         Map<Integer,Map<String,Object>> groups=new LinkedHashMap<>();
    263 |         for (Map<String,Object> inv:included) {
    264 |             subtotal=subtotal.add(new BigDecimal((String)inv.get("subtotal")));
    265 |             gst=gst.add(new BigDecimal((String)inv.get("gst")));
    266 |             total=total.add(new BigDecimal((String)inv.get("total")));
    267 |             int customerId=(Integer)inv.get("customerId");
    268 |             Map<String,Object> group=groups.get(customerId);
    269 |             if (group==null) { group=map("customerId",customerId,"customerName",inv.get("customerName"),
    270 |                 "invoiceCount",0,"total","0.00"); groups.put(customerId,group); }
    271 |             group.put("invoiceCount",(Integer)group.get("invoiceCount")+1);
    272 |             group.put("total",money(new BigDecimal((String)group.get("total")).add(new BigDecimal((String)inv.get("total")))));
    273 |         }
    274 |         BigDecimal credits=db.queryForObject("SELECT COALESCE(SUM(total),0) FROM credit_notes WHERE FORMATDATETIME(credit_date,'yyyy-MM')=?",
    275 |             BigDecimal.class,month);
    276 |         return map("month",month,"invoiceCount",included.size(),"subtotal",money(subtotal),"gst",money(gst),
    277 |             "total",money(total),"credits",money(credits),"netTotal",money(total.subtract(credits)),
    278 |             "byCustomer",new ArrayList<>(groups.values()));
    279 |     }
    280 |     public Map<String,Object> annual(int year) {
    281 |         if (year<2000 || year>9999) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"year must be four digits");
    282 |         List<Map<String,Object>> months=new ArrayList<>();
    283 |         for (int n=1;n<=12;n++) {
    284 |             String month=String.format("%04d-%02d",year,n);
    285 |             BigDecimal invoiced=db.queryForObject("SELECT COALESCE(SUM(gst),0) FROM invoices WHERE FORMATDATETIME(invoice_date,'yyyy-MM')=?",BigDecimal.class,month);
    286 |             BigDecimal credited=db.queryForObject("SELECT COALESCE(SUM(gst),0) FROM credit_notes WHERE FORMATDATETIME(credit_date,'yyyy-MM')=?",BigDecimal.class,month);
    287 |             months.add(map("month",month,"gstInvoiced",money(invoiced),"gstCredited",money(credited),
    288 |                 "gstNet",money(invoiced.subtract(credited))));
    289 |         }
    290 |         return map("year",year,"months",months);
    291 |     }
    292 | }
    293 | 
  3. 3

    Bob checks every call against the line it sits on. Each link from one part of the code to another is confirmed against the exact line, and code that nothing calls is recorded as unwired rather than guessed at.

    1. Desk.customers(q) call site — DeskController.java:34: return desk.customers(q); — ✓ callee customers present on line 34 2. Desk.products() call site — DeskController.java:36: return desk.products(); — ✓ line 36
    InvoiceRunJobV2 — NOT mapped in jobs.properties; no call edge, recorded as unwired.
  4. 4

    Bob records the evidence map. The result is a numbered list of evidence that later chapters must cite, so every claim can be traced back to the code.

    RangeSubject
    EV-001–EV-008Documentation notes for all 7 .java files + desk.js
    EV-009–EV-011Config: H2 datasource, schedule flags, jobs.properties registry
    EV-012–EV-013SQL injection risk in customers and invoices LIKE queries
    EV-014Preview/save discount boundary discrepancy (>=100 vs >100)
    EV-015Coarse synchronized on save/requestCredit; invoiceBatch unsynchronized
    EV-016Reflective dispatch via deprecated Class.forName().newInstance()
    EV-017DDL + seed data embedded in @PostConstruct; no migration tooling
    EV-018H2-specific FORMATDATETIME in monthly/annual; RDBMS migration blocker
    EV-019DeskController directly accesses desk.db package-private field
    EV-020–EV-021InvoiceRunJobV2 is an unwired no-op stub; jobs.properties load path
    EV-022–EV-055Dedicated call-site rows (one per call site, verbatim quotes)
    EV-056–EV-058Unknown: telemetry, backup/DR, deployment topology
    EV-059Inference: InvoiceRunJobV2 likely abandoned rewrite

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: the full exclusion list and what Bob read

Kept out of the copy

  • .git/
  • .env and .env.* (except .env.example)
  • **/secrets/, **/*credentials*.json, **/*.pem, **/*.key
  • node_modules, __pycache__, *.pyc, target, bin, obj
  • *.sqlite3, *.sqlite3-*, *.log
  • Earlier run folders, an internal maintenance skill and internal reference material

We also swept the whole application folder, including files Bob never saw, for secrets: 0 matches in 26 files.

What Bob opened in his Discover session

  • Desk.java, DeskController.java, ScheduledJobs.java
  • Job.java, InvoiceRunJob.java, InvoiceRunJobV2.java, CreditNoteJob.java
  • application.properties, jobs.properties, desk.js
  • The stage brief and the list of source files

A sample of the calls Bob mapped, with the line each one sits on

FromCallsSource line
DeskController.customersDesk.customersDeskController.java:34
DeskController.previewDesk.calculateDeskController.java:42
DeskController.saveDesk.saveDeskController.java:45
ScheduledJobs.invoiceRunDesk.runJobScheduledJobs.java:13
Desk.invoiceBatchDesk.saveDesk.java:235
Desk.saveDesk.calculateDesk.java:178

Open the file in app/ at that line and the call is there. The SI's checker did exactly that for every call.

Bob documents the legacy code as he reads it

Documentation note (Desk): The class carries no Javadoc or inline comments on any public method. Field and constructor names are self-descriptive (db, today, jobs, GST). The @PostConstruct method `setup` is named but not documented. Helper methods (map, money, unit, decimal, integer, required, missing) are undocumented. The absence of comments makes intent of the qty>=100 vs qty>100 discount boundary ambiguity non-obvious to a maintainer.

Bob, Discover evidence

The guard at work

The kit's guard checks every file Bob tries to open. When Bob tried a wildcard file search (in the Plan stage), the guard stopped it with: Blocked: file operation did not provide a reviewable path. Bob then listed the folder and opened the files he needed by name.

02

SI architect · runs the assessment

Assess: what's wrong, and how bad

Every finding points to the lines it came from, so your architect checks Bob's work instead of redoing it.

Of the 23 findings, 1 is critical, 8 high, 10 medium and 4 low.

Score tech debt and complexity

Bob scores every class for technical debt, using a rule the SI sets.

  1. Desk5
  2. DeskController3
  3. ScheduledJobs2
  4. InvoiceRunJobV22
  5. Job2
  6. DeskMoneyTest2
  7. Six more classes1 each

Debt score per class, out of 5: SI-measured, Bob-scored.

  1. Desk scores the maximum: 29 methods, one of them with 12 branches, and security findings on top.

Find security weaknesses in the code

Eleven of the 23 findings sit in one file, and one of them is the line we follow.

Header Start-up Helpers Lookup Pricing Invoices Pricing Inv. Credits Batch jobs Reporting F-011 F-014 F-001 · lines 93 and 129–132 F-004 F-007 F-012 F-013 F-008 F-015 F-005 F-016 F-001 · lines 93 and 129–132

Desk.java, all 292 lines, drawn to scale and split into the jobs it does (blue); grey is the header and the last line. Each pin is a finding at the lines Bob cites; the orange one is F-001.

SQL injection via string-concatenated LIKE clauses in customers() and invoices()

Bob, finding F-001, Assess

In plain words: the search text is glued straight into the database query. Bob rated it critical, his top rating.

  1. F-001 grows out of Discover's evidence for line 93. The same mistake repeats at lines 129–132. Its work package.
  2. Bob read the code; no security scanner was run. He says so himself: The absence of scanner results is not a clean score.

Pick modernisation paths

Bob weighs three ways forward and recommends where to start. The SI chooses, with the client.

Option 1

Retain and contain (Java 8 / Spring Boot 2.7.18 / H2)

Fix the security defects, change nothing else. Fastest, but leaves the out-of-support platform in place.

Option 2 · recommended

Bounded migration: Spring Boot 3 / Java 21 + H2 to PostgreSQL

Upgrade the platform and move to a production database, keeping the business logic as it is.

Option 3

Deeper modernisation (Kotlin / Microservice / FastAPI / ASP.NET Core)

Rebuild or split the application. Bob judges it not needed yet for an estate this size.

bounded migration with H2-to-PostgreSQL database replacement and Spring Boot 3 / Java 21 upgrade as the minimum viable modernisation path, with a separately authorized Java invoice-preview pilot before committing to full migration scope.

Bob's recommendation, assessment report
  1. Whichever path is chosen, Bob is clear about today: NO-GO for production deployment in the current state. Security findings F-001, F-002 and F-003 must be remediated and verified before any production exposure. F-002 and F-003 are two other security findings, outside Desk.java. That is why the plan starts with the security fixes: next, F-001 becomes a work package with hours.
Watch Bob do it: his Assess session, in three moments

In this recording Bob numbers the injection defect F-002 and rates it high; in the final report it is F-001, rated critical. Our commentary is on the left; the dark panels are what Bob read, said and wrote.

  1. 1

    Bob checks the help-desk history. He reads the sample support tickets so a finding can point to what users actually reported, not only to the code.

    Now let me query the tickets tools for operational history corroboration.

    tickets · tickets get id=DESK-1008

    {"category": "application_behaviour", "channel": "phone", "description": "Opening invoice history took a long time this morning and the page seemed to hang for a while before the list appeared. It was fine again by lunch.", "id": "DESK-100…
    Full output
    {"category": "application_behaviour", "channel": "phone", "description": "Opening invoice history took a long time this morning and the page seemed to hang for a while before the list appeared. It was fine again by lunch.", "id": "DESK-1008", "reported_on": "2026-01-06", "reporter_role": "Accounts receivable clerk", "resolution_note": "Could not reproduce later in the day. Passed to the application team for awareness; no cause recorded.", "status": "closed", "synthetic": true, "title": "Desk felt very slow first thing after the new year", "type": "ticket"}
  2. 2

    Bob rates the injection defect and ties it to the code. The search-box defect becomes a security finding that names its evidence and the exact lines in the source. The rating is Bob's judgement, and a reviewer checks it.

    F-002: SQL injection via string concatenation (category: security) — EV-012, EV-013, EV-030, EV-032, EV-044, EV-046 — severity: high

    grep reports/findings.json

      Line 33:       "id": "F-002",
      Line 36:       "impact": "An attacker who can reach the /api/customers or /api/invoices endpoints can inject arbitrary SQL into H2. While H2 is file-local and the server is bound to localhost (EV-009), the pattern is a high-risk poor pra…
      Line 37:       "evidence_ids": ["EV-012","EV-013","EV-030","EV-032","EV-044","EV-046"],
      Line 39:       "rationale": "EV-012 and EV-013 document direct string concatenation of the HTTP query parameter q into SQL WHERE clauses in Desk.customers (line 93) and Desk.invoices (line 129). EV-030 and EV-032 confirm DeskController p…
    All 104 lines
    Found 122 matches (showing first 100)
    reports/findings.json:
      Line 6:     "EV-001","EV-002","EV-003","EV-004","EV-005","EV-006","EV-007","EV-008",
      Line 7:     "EV-009","EV-010","EV-011","EV-012","EV-013","EV-014","EV-015","EV-016",
      Line 8:     "EV-017","EV-018","EV-019","EV-020","EV-021","EV-022","EV-023","EV-024",
      Line 9:     "EV-025","EV-026","EV-027","EV-028","EV-029","EV-030","EV-031","EV-032",
      Line 10:     "EV-033","EV-034","EV-035","EV-036","EV-037","EV-038","EV-039","EV-040",
      Line 11:     "EV-041","EV-042","EV-043","EV-044","EV-045","EV-046","EV-047","EV-048",
      Line 12:     "EV-049","EV-050","EV-051","EV-052","EV-053","EV-054","EV-055","EV-056",
      Line 13:     "EV-057","EV-058","EV-059"
      Line 17:       "id": "F-001",
      Line 20:       "impact": "Maintainers must infer intent from naming alone. The qty>=100 vs qty>100 discount boundary ambiguity (see F-003) is non-obvious without comments. Onboarding time increases; risk of silent behavior change rises with each migration or refactor.",
      Line 21:       "evidence_ids": ["EV-001","EV-002","EV-003","EV-004","EV-005","EV-006","EV-007","EV-008"],
      Line 23:       "rationale": "All eight inspected Java and JavaScript source files contain zero Javadoc and no substantive inline comments. EV-001 through EV-008 confirm the absence per-file. Severity is low because naming is largely self-descriptive; the impact compounds when combined with higher-severity behavioral and coupling findings.",
      Line 33:       "id": "F-002",
      Line 36:       "impact": "An attacker who can reach the /api/customers or /api/invoices endpoints can inject arbitrary SQL into H2. While H2 is file-local and the server is bound to localhost (EV-009), the pattern is a high-risk poor practice that must be remediated before any network-exposed deployment.",
      Line 37:       "evidence_ids": ["EV-012","EV-013","EV-030","EV-032","EV-044","EV-046"],
      Line 39:       "rationale": "EV-012 and EV-013 document direct string concatenation of the HTTP query parameter q into SQL WHERE clauses in Desk.customers (line 93) and Desk.invoices (line 129). EV-030 and EV-032 confirm DeskController passes the raw parameter. EV-044 and EV-046 confirm the frontend submits user-typed input directly. The localhost bind mitigates external exposure but does not eliminate the risk in an internal or forwarded context.",
      Line 50:       "id": "F-003",
      Line 54:       "evidence_ids": ["EV-014","EV-022","EV-023","EV-034","EV-049","EV-050"],
      Line 56:       "rationale": "EV-014 documents the boundary: previewLineNet applies qty>=100 (line 107-113) and bookedLineNet applies qty>100 (line 114-120). EV-022 and EV-023 trace the save path (calculate(input,false) then invoice(id)). EV-034 and EV-049 trace the preview path (calculate(body,true) from controller and frontend). EV-050 confirms the save path from the frontend. The discrepancy is deterministic and reproducible.",
      Line 67:       "id": "F-004",
      Line 70:       "impact": "Under concurrent HTTP and scheduler execution, invoiceBatch (line 226) may re-enter concurrently with itself or with a live HTTP save, producing duplicate invoices for the same order. The synchronized modifier on save and requestCredit does not cover the batch path. Production concurrency profile is unknown (EV-058).",
      Line 71:       "evidence_ids": ["EV-015","EV-024","EV-035","EV-037","EV-050"],
      Line 73:       "rationale": "EV-015 documents the synchronization gap. EV-024 shows invoiceBatch calls save (which is synchronized), but the outer invoiceBatch loop is not itself synchronized. EV-035 and EV-037 confirm save and requestCredit are protected. EV-050 shows the HTTP save path from the frontend. Because the production concurrency profile is unknown, severity is medium rather than high; if multiple concurrent users exist it is high.",
      Line 84:       "id": "F-005",
      Line 88:       "evidence_ids": ["EV-016","EV-025","EV-040","EV-041","EV-052","EV-053"],
      Line 90:       "rationale": "EV-016 documents the deprecated newInstance() call at Desk line 220-224. EV-025 shows the call site returning job result. EV-040, EV-041, EV-052, EV-053 confirm both HTTP-triggered and scheduled job paths use this mechanism. Severity is medium because the deprecated API still functions under Java 21 but is a migration friction point and latent fragility.",
      Line 101:       "id": "F-006",
      Line 105:       "evidence_ids": ["EV-017"],
      Line 107:       "rationale": "EV-017 documents the embedded DDL and conditional seed data in setup(). The IF NOT EXISTS pattern prevents re-seeding but provides no schema evolution path. Any schema change requires a coordinated application redeployment with manual data handling.",
      Line 118:       "id": "F-007",
      Line 122:       "evidence_ids": ["EV-018","EV-009","EV-042","EV-043","EV-054","EV-055"],
      Line 124:       "rationale": "EV-018 identifies FORMATDATETIME usage at Desk lines 274, 285, 286, 259. EV-009 confirms H2 is the runtime datasource. EV-042 and EV-043 show the monthly/annual call-sites from DeskController. EV-054 and EV-055 show the frontend triggers. This is a hard portability blocker, not a warning.",
      Line 135:       "id": "F-008",
      Line 138:       "impact": "The Desk class (282 source lines, 29 methods) concentrates all business logic, SQL, batch processing, reporting and concurrency in a single class. DeskController delegates every operation to Desk with no intermediary service layer. EV-019 shows the health endpoint directly accesses the package-private desk.db JdbcTemplate field, bypassing the public API. This coupling makes any extraction or decomposition — including the preview-service target — require refactoring Desk first.",
      Line 139:       "evidence_ids": ["EV-019","EV-031","EV-033","EV-036","EV-038","EV-039","EV-045","EV-047","EV-048","EV-051"],
      Line 141:       "rationale": "EV-019 documents the direct field access violation. EV-031, EV-033, EV-036, EV-038, EV-039 show DeskController delegates all operations directly to Desk with no service boundary. EV-045, EV-047, EV-048, EV-051 show the frontend call pattern mirrors the controller's one-to-one Desk delegation. The absence of a service layer means any API boundary for microservice extraction must be designed from scratch.",
      Line 152:       "id": "F-009",
      Line 156:       "evidence_ids": ["EV-006","EV-011","EV-020","EV-026","EV-059"],
      Line 158:       "rationale": "EV-006 and EV-020 document the no-op stub and comment. EV-011 confirms InvoiceRunJobV2 is not mapped in jobs.properties. EV-026 confirms InvoiceRunJob (not V2) is the active invoice batch class. EV-059 is the inference that this was an abandoned rewrite. Classified as dead-code candidate, not confirmed dead code, because no call-graph or runtime evidence confirms it is unreachable in all deployment variants — only that it is unmapped in the current configuration.",
      Line 168:       "id": "F-010",
      Line 171:       "impact": "Spring Boot 2.7.x reached end-of-commercial-support in November 2023. Java 8 reached Oracle public end-of-life in March 2022. The pom.xml (BASE/app/pom.xml, inspected as source_config during assessment) declares spring-boot-starter-parent 2.7.18 and java.version=1.8. EV-016 corroborates the Java 8 posture via use of the Class.newInstance() API deprecated in Java 9. Running on an EOL platform means no security patches, no bug fixes and no compatibility guarantees for library updates.",
      Line 172:       "evidence_ids": ["EV-016","EV-017"],
      Line 174:       "rationale": "EV-016 documents deprecated Java 9+ API use confirming a Java 8 source target. EV-017 documents H2 embedded DDL indicating a demo-grade platform posture. The pom.xml was read as part of the BASE source inspection and records Spring Boot 2.7.18 with java.version 1.8; no separate EV was generated for the pom during discovery. Severity is high because EOL platforms accumulate unpatched CVEs. A version-specific vulnerability scan was not run (source-reading review, no scanner).",
      Line 179:         "Replace deprecated Class.newInstance() as part of the Java upgrade (see F-005).",
      Line 185:       "id": "F-011",
      Line 188:       "impact": "DeskMoneyTest.java contains one test method: moneyUsesTwoPlaces(). The discount boundary discrepancy (F-003) and the synchronization gap (F-004) — both high-to-medium severity — have no automated test coverage. There are no integration tests for the HTTP endpoints, no tests for the batch jobs, and no tests for the SQL queries. Any migration carries elevated regression risk.",
      Line 189:       "evidence_ids": ["EV-014","EV-015"],
      Line 191:       "rationale": "EV-014 documents the discount boundary discrepancy; the absence of a test for this is itself evidence of the coverage gap. EV-015 documents the synchronization gap; again no test covers this path. The test file DeskMoneyTest.java (inspected as part of the source inventory) contains a single assertion confirming two-decimal formatting. Complexity metrics confirm 29 methods in Desk with 57 total branches across the codebase; one test covers zero branches of the critical calculation paths.",
      Line 203:       "id": "F-012",
      Line 206:       "impact": "The Job interface (EV-004) declares only int execute(Desk desk) with no documented behavior for failure. The ScheduledJobs enabled guard (EV-010) fires and forgets; an exception in runJob or a job propagates uncaught, potentially silently suppressing a failed invoice batch. There is no idempotency mechanism: if the scheduler fires twice in a window, duplicate processing may result.",
      Line 207:       "evidence_ids": ["EV-004","EV-010","EV-021","EV-027","EV-028","EV-029"],
      Line 209:       "rationale": "EV-004 notes the Job interface has no error contract. EV-010 documents the guard pattern without error handling. EV-021 shows the jobs.properties loading can throw IllegalStateException on missing file. EV-027 and EV-028/EV-029 show the call chain from scheduler to job to batch method with no try/catch at any layer above the individual synchronized blocks. Severity is medium; the schedule is disabled by default (EV-010), limiting immediate production exposure.",
      Line 221:       "id": "F-013",
      Line 224:       "impact": "Production call volumes, error rates, latency and peak load are entirely unknown. DESK-1008 (ticket corroboration — EV-056) reports a slow-load episode whose root cause was never confirmed. Without APM, root cause of availability events cannot be determined and migration performance baselines cannot be set.",
      Line 225:       "evidence_ids": ["EV-056"],
      Line 227:       "rationale": "EV-056 documents the absence of any APM or tracing configuration in application.properties. No Micrometer, Prometheus, Spring Actuator metrics export or distributed tracing dependency is present in the pom.xml. DESK-1008 (synthetic user report) corroborates an undiagnosed slowness episode; this is cited as corroboration only alongside EV-056.",
      Line 238:       "id": "F-014",
      Line 241:       "impact": "The H2 file database at runtime/data/base (EV-009) has no observable backup or DR configuration. Loss of the host or storage volume would result in permanent data loss of all invoices, customers, products and credit records. DESK-1021 (incident — corroboration) records a twenty-minute outage linked to a host restart, confirming single-node exposure.",
      Line 242:       "evidence_ids": ["EV-057","EV-009"],
      Line 244:       "rationale": "EV-057 documents the absence of any backup or DR configuration in source. EV-009 identifies the H2 file path (runtime/data/base). DESK-1021 (synthetic incident, cited as corroboration alongside EV-057) records a host-restart incident where the application was unavailable for twenty minutes. This is cited as corroboration only; the severity is derived from the absence of DR evidence in source, not from the ticket alone.",
      Line 255:       "id": "F-015",
      Line 259:       "evidence_ids": ["EV-058"],
      Line 261:       "rationale": "EV-058 documents the topology gap as an inference. The localhost bind (EV-009) supports the single-node inference but does not confirm it. No Dockerfile, docker-compose, Kubernetes manifest, or infrastructure-as-code was found in the BASE source tree.",
      Line 272:     {"evidence_id":"EV-001","disposition":"mapped","finding_ids":["F-001"],"owner":null,"reason":null},
      Line 273:     {"evidence_id":"EV-002","disposition":"mapped","finding_ids":["F-001"],"owner":null,"reason":null},
      Line 274:     {"evidence_id":"EV-003","disposition":"mapped","finding_ids":["F-001"],"owner":null,"reason":null},
      Line 275:     {"evidence_id":"EV-004","disposition":"mapped","finding_ids":["F-001","F-012"],"owner":null,"reason":null},
      Line 276:     {"evidence_id":"EV-005","disposition":"mapped","finding_ids":["F-001"],"owner":null,"reason":null},
      Line 277:     {"evidence_id":"EV-006","disposition":"mapped","finding_ids":["F-001","F-009"],"owner":null,"reason":null},
      Line 278:     {"evidence_id":"EV-007","disposition":"mapped","finding_ids":["F-001"],"owner":null,"reason":null},
      Line 279:     {"evidence_id":"EV-008","disposition":"mapped","finding_ids":["F-001"],"owner":null,"reason":null},
      Line 280:     {"evidence_id":"EV-009","disposition":"mapped","finding_ids":["F-007","F-014"],"owner":null,"reason":null},
      Line 281:     {"evidence_id":"EV-010","disposition":"mapped","finding_ids":["F-012"],"owner":null,"reason":null},
      Line 282:     {"evidence_id":"EV-011","disposition":"mapped","finding_ids":["F-009"],"owner":null,"reason":null},
      Line 283:     {"evidence_id":"EV-012","disposition":"mapped","finding_ids":["F-002"],"owner":null,"reason":null},
      Line 284:     {"evidence_id":"EV-013","disposition":"mapped","finding_ids":["F-002"],"owner":null,"reason":null},
      Line 285:     {"evidence_id":"EV-014","disposition":"mapped","finding_ids":["F-003","F-011"],"owner":null,"reason":null},
      Line 286:     {"evidence_id":"EV-015","disposition":"mapped","finding_ids":["F-004","F-011"],"owner":null,"reason":null},
      Line 287:     {"evidence_id":"EV-016","disposition":"mapped","finding_ids":["F-005","F-010"],"owner":null,"reason":null},
      Line 288:     {"evidence_id":"EV-017","disposition":"mapped","finding_ids":["F-006","F-010"],"owner":null,"reason":null},
      Line 289:     {"evidence_id":"EV-018","disposition":"mapped","finding_ids":["F-007"],"owner":null,"reason":null},
      Line 290:     {"evidence_id":"EV-019","disposition":"mapped","finding_ids":["F-008"],"owner":null,"reason":null},
      Line 291:     {"evidence_id":"EV-020","disposition":"mapped","finding_ids":["F-009"],"owner":null,"reason":null},
      Line 292:     {"evidence_id":"EV-021","disposition":"mapped","finding_ids":["F-012"],"owner":null,"reason":null},
      Line 293:     {"evidence_id":"EV-022","disposition":"mapped","finding_ids":["F-003"],"owner":null,"reason":null},
      Line 294:     {"evidence_id":"EV-023","disposition":"mapped","finding_ids":["F-003"],"owner":null,"reason":null},
      Line 295:     {"evidence_id":"EV-024","disposition":"mapped","finding_ids":["F-004"],"owner":null,"reason":null},
      Line 296:     {"evidence_id":"EV-025","disposition":"mapped","finding_ids":["F-005"],"owner":null,"reason":null},
      Line 297:     {"evidence_id":"EV-026","disposition":"mapped","finding_ids":["F-009"],"owner":null,"reason":null},
      Line 298:     {"evidence_id":"EV-027","disposition":"mapped","finding_ids":["F-012"],"owner":null,"reason":null},
      Line 299:     {"evidence_id":"EV-028","disposition":"mapped","finding_ids":["F-012"],"owner":null,"reason":null},
      Line 300:     {"evidence_id":"EV-029","disposition":"mapped","finding_ids":["F-012"],"owner":null,"reason":null},
      Line 301:     {"evidence_id":"EV-030","disposition":"mapped","finding_ids":["F-002"],"owner":null,"reason":null},
      Line 302:     {"evidence_id":"EV-031","disposition":"mapped","finding_ids":["F-008"],"owner":null,"reason":null},
      Line 303:     {"evidence_id":"EV-032","disposition":"mapped","finding_ids":["F-002"],"owner":null,"reason":null},
      Line 304:     {"evidence_id":"EV-033","disposition":"mapped","finding_ids":["F-008"],"owner":null,"reason":null},
      Line 305:     {"evidence_id":"EV-034","disposition":"mapped","finding_ids":["F-003"],"owner":null,"reason":null},
      Line 306:     {"evidence_id":"EV-035","disposition":"mapped","finding_ids":["F-004"],"owner":null,"reason":null},
      Line 307:     {"evidence_id":"EV-036","disposition":"mapped","finding_ids":["F-008"],"owner":null,"reason":null},
      Line 308:     {"evidence_id":"EV-037","disposition":"mapped","finding_ids":["F-004"],"owner":null,"reason":null},
    
    (Results truncated. Use a more specific path or pattern.)
  3. 3

    Bob says what he did not do. The report states plainly that this was a careful reading of the code and not an automated scan, so nobody reads more into it than is there.

    grep reports/assessment.md

    reports/assessment.md:
      Line 201: Review basis: source-reading review, no scanner. This assessment reads Java source, JavaScript, configuration files and pom.xml. No automated security scanner, dependency vulnerability database check, SAST tool or penetration t…
    All 10 lines
    Found 8 matches
    reports/assessment.md:
      Line 141: **Scoring rubric:** Scale 1–5 (1 = minimal debt, 5 = severe). Base score starts at 1. Adjustments: `lines_code` ≥ 100 in a single file adds 1 point; `method_count` ≥ 15 in a single file adds 1 point; `max_method_branch_count` ≥ 8 adds 1 point, ≥ 12 adds 2 points. Linked finding categories adjust as follows: each `security` finding on the component adds 1 point; each `coupling` or `architecture` finding adds 0.5 (capped at 1 per category); each `error_data_handling` finding adds 0.5 (capped at 1 per component); `obsolete_dependency` or `dead_code_candidate` on the component adds 0 to the component score (applied at portfolio level). Final score capped at 5.
      Line 143: | Component | Source path | lines_code / method_count / max_method_branch_count | Score and rubric reason | Finding IDs / evidence IDs |
      Line 180: ## Operational history and ticket corroboration
      Line 182: Tickets and incidents were read through the governed synthetic tickets tools (`mcp__tickets__tickets_search`, `mcp__tickets__tickets_get`, `mcp__tickets__incidents_list`). These are synthetic user reports; they are not diagnoses and are cited only as corroboration alongside primary `EV-` evidence. Every line below that pairs a ticket ID with a finding also cites the primary EV- evidence.
      Line 191: | DESK-1013 | Yes — request for date-range filter on invoice history | Corroborates F-008 (EV-019 — Desk God-object makes feature addition high-effort) and F-001 (EV-001 — undocumented Desk internals) | Feature request; not a defect; cited as corroboration only |
      Line 192: | DESK-1019 | Partially — invoice preview prints across two pages | Corroborates F-008 (EV-019 — tight frontend/backend coupling; no server-side PDF rendering) | Browser print layout issue; not a backend code defect; cited as corroboration of missing presentation capability |
      Line 195: No ticket was placed in findings `evidence_ids`. No severity was raised on ticket evidence alone. The corroboration above is supplementary to the primary EV- source evidence.
      Line 201: Review basis: source-reading review, no scanner. This assessment reads Java source, JavaScript, configuration files and pom.xml. No automated security scanner, dependency vulnerability database check, SAST tool or penetration test was performed. A dependency CVE scan (e.g., OWASP Dependency-Check or Snyk) against the pom.xml has not been run and would establish specific advisory identifiers for Spring Boot 2.7.18, H2 2.2.224 and the spring-boot-starter-web/jdbc components.

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: every finding in Desk.java, the scoring rule and the seven paths
FindingBob's titleRatingLines
F-011GST rate hardcoded in two locations — constant duplicationlow29, 248
F-014Schema management via inline DDL at @PostConstruct; no migration frameworkmedium36–59
F-001SQL injection via string-concatenated LIKE clauses in customers() and invoices()critical93, 129–132
F-004Preview/save bulk-discount off-by-one: qty=100 discounted in preview but not in savehigh107–119
F-00750-line invoice limit enforced server-side only; browser UI provides no feedbacklow153
F-012Coarse instance-level synchronized methods serialize all invoice and credit operationsmedium177, 194
F-013Invoice and credit-request IDs generated via COALESCE MAX+1, no database sequencemedium179, 194
F-008Credit requests auto-approved with no workflow or authorization checkmedium199–201
F-015Reflective job dispatch uses deprecated Class.newInstance(); no compile-time bindingmedium220–225
F-005Batch jobs lack outer transaction — partial failure leaves inconsistent datahigh226–255
F-016H2-specific FORMATDATETIME SQL function used in reporting querieshigh259, 285–286

The scoring rule Bob applies

base 1; lines_code >=100 +1; method_count >=15 +1; max_method_branch_count >=8 +1, >=12 +2; each security finding +1; coupling/architecture +0.5 (cap 1 per category); error_data_handling +0.5 (cap 1); obsolete_dependency/dead_code 0; cap 5

The SI's tool measures the inputs; Bob applies the rule and writes down why each class got its score.

How Bob weighs the options

Bob scored the options for 5 components using the 7 Rs, each rated High / Medium / Low on business criticality, technical fit, effort basis, delivery risk and reversibility.

Not available

How often each file changes is not shown: the application arrived as a single snapshot with no change history, so there is nothing to count.

F-001 in full, as Bob wrote it

Direct string interpolation of unvalidated user input into SQL LIKE clauses (Desk.java:93 and :129-132) is a textbook SQL injection defect. No parameterization is used. H2 supports stacked queries and function-based exfiltration. Severity is critical because exploitation requires no authentication beyond API access and could compromise the full data store.

Bob's reasoning for the rating
  • Fix: Replace string concatenation with parameterized PreparedStatement LIKE queries using '?' placeholders.
  • Prove it: Add an integration test that confirms a single-quote in the q parameter returns a 200 with safe results, not a 500 or altered row set.
  • Check the rest: Review all other Desk.java query builders for the same pattern before finalizing remediation scope.
  • Who decides: Client security owner and delivery tech lead (pending)
  • How it was found: Bob read the source. No scanner was run, so its status stays under_investigation until a security owner confirms it.

How the assessment's view of each target compares with what the migration then showed: see Seven small migrations.

The seven migration paths, as Bob judged them

  • Java 21Recommended firstproceed with Java 21 upgrade as the first mandatory step of the bounded migration path (Option 2)Walkthrough
  • PostgreSQLrequired for any production deployment; highest-impact structural change; after the Java 21 / Spring Boot 3 upgradeWalkthrough
  • TypeScriptlow-risk quality improvement, recommended as a parallel trackWalkthrough
  • Kotlinnot recommended as the primary migration path at this time; viable long term after the bounded migrationWalkthrough
  • Microserviceviable future option, not a recommended immediate actionWalkthrough
  • Python (FastAPI)not recommended as the primary path; viable for a greenfield preview service; full rewriteWalkthrough
  • .NET (ASP.NET Core)not recommended as the primary path; viable if the client standard is .NET; full rewriteWalkthrough

For this demo we asked Bob for all seven, each the same small slice, to show his range: they are in Part 2.

03

Delivery lead · owns the plan and team

Plan: who does what, in what order

Bob hands you a plan you could staff: packages, order, people, and the test that says each one is done.

Low 252 hours

Central 362 hours

High 512 hours

Hours for the whole plan, pilot excluded, from Bob's effort model.

Bob's estimate: working days × productive hours a day by role (a developer: 5–10, central 7).

Break into work packages

Bob puts the work in four waves, security fixes first.

Wave 1
Security fixes and test foundation
61 / 91 / 130
Wave 2
The pilot, approved on its own · not in the plan's totals
28 / 40 / 56
Wave 3
The bounded migration (Option 2)
161 / 229 / 322
Wave 4
Owner decisions and handover
30 / 42 / 60

Bar length is each wave's central hours, to scale. The orange mark is the fix for line 93.

  1. WBS-1.1 has nothing before it, so it can start on day one.

Size effort per module

Every package is sized in hours, as a range, with its reasons.

The package we follow · DEL-001 (WBS-1.1), the fix for line 93

SQL injection remediation — parameterize customers() and invoices() queries

Low 15 hours

Central 21 hours

High 30 hours

Hours, one SI developer.

Done when: T-101: parameterized queries replace string concatenation in Desk.java:93 and :129-132; integration test asserts single-quote in q param returns safe result; no 500 on injection attempt.

  1. These are Bob's forecasts: no hours have been worked yet.
  2. Bob's reason for the range on DEL-001: Low assumes both sites are mechanically similar; high allows for additional undiscovered injection patterns noted in finding F-001. Its price.

Map skills to team

Bob matches the packages to people, and says where the team falls short.

  1. SI developer266 h
  2. SI tech lead36 h
  3. SI PM30 h
  4. SI QA18 h
  5. SI senior reviewer12 h

Central hours by role, from the effort model.

  1. Bob checked a synthetic team of 12 people for skills and dates: see the team. His table of the people for each package is in Go deeper below.
  2. For the package we follow, he found a gap: Dedicated security engineer (SE-09) unavailable until 2027-01-04 — Wave 1 WBS-1.1 relies on SE-01 Application security coverage at 50%. Bob flags the gap; who covers it is the delivery lead's call.
  3. Next: a pilot, then the price.
Watch Bob do it: his Plan session, in four moments

In this recording Bob numbers the injection defect F-002, and his work packages differently; in the final report it is F-001. Our commentary is on the left; the dark panels are what Bob read, said and wrote.

  1. 1

    Bob checks who is available. He asks the sample team roster who can work on the project and when, so the plan is built around real people rather than an ideal team.

    roster · roster list people

    {"count": 12, "people": [{"availability": [{"allocation_percent": 50, "from": "2026-10-26", "to": "2026-12-18"}, {"allocation_percent": 100, "from": "2027-01-04", "to": "2027-03-31"}], "id": "SE-01", "name": "Synthetic Engineer 01", "ratec…
    Full output
    {"count": 12, "people": [{"availability": [{"allocation_percent": 50, "from": "2026-10-26", "to": "2026-12-18"}, {"allocation_percent": 100, "from": "2027-01-04", "to": "2027-03-31"}], "id": "SE-01", "name": "Synthetic Engineer 01", "ratecard_role": "Solution architect", "ratecard_role_basis": "exact rate-card role", "role": "Solution architect", "seniority": "principal", "skills": ["Solution architecture", "Java 8", "Java 21", "Spring Boot 3", "PostgreSQL", "Application security"], "synthetic": true}, {"availability": [{"allocation_percent": 100, "from": "2026-11-02", "to": "2027-02-26"}], "id": "SE-02", "name": "Synthetic Engineer 02", "ratecard_role": "Solution architect", "ratecard_role_basis": "exact rate-card role", "role": "Solution architect", "seniority": "senior", "skills": ["Solution architecture", "Java 21", "Spring Boot 3", "CI/CD", "Docker"], "synthetic": true}, {"availability": [{"allocation_percent": 100, "from": "2026-10-19", "to": "2027-01-29"}], "id": "SE-03", "name": "Synthetic Engineer 03", "ratecard_role": "Migration engineer", "ratecard_role_basis": "exact rate-card role", "role": "Migration engineer", "seniority": "senior", "skills": ["Java 8", "Java 21", "Spring Boot 2", "Spring Boot 3", "Maven", "JUnit 5"], "synthetic": true}, {"availability": [{"allocation_percent": 80, "from": "2026-11-16", "to": "2027-03-31"}], "id": "SE-04", "name": "Synthetic Engineer 04", "ratecard_role": "Migration engineer", "ratecard_role_basis": "exact rate-card role", "role": "Migration engineer", "seniority": "senior", "skills": ["Java 21", "Spring Boot 3", "PostgreSQL", "Flyway", "JUnit 5"], "synthetic": true}, {"availability": [{"allocation_percent": 100, "from": "2026-10-19", "to": "2026-12-11"}, {"allocation_percent": 100, "from": "2027-01-11", "to": "2027-03-31"}], "id": "SE-05", "name": "Synthetic Engineer 05", "ratecard_role": "Migration engineer", "ratecard_role_basis": "exact rate-card role", "role": "Migration engineer", "seniority": "mid", "skills": ["Java 8", "Java 21", "Spring Boot 3", "Maven", "Docker"], "synthetic": true}, {"availability": [{"allocation_percent": 100, "from": "2026-12-01", "to": "2027-03-31"}], "id": "SE-06", "name": "Synthetic Engineer 06", "ratecard_role": "Migration engineer", "ratecard_role_basis": "exact rate-card role", "role": "Migration engineer", "seniority": "mid", "skills": ["Java 21", "Spring Boot 3", "PostgreSQL", "Performance testing"], "synthetic": true}, {"availability": [{"allocation_percent": 100, "from": "2026-10-19", "to": "2027-03-31"}], "id": "SE-07", "name": "Synthetic Engineer 07", "ratecard_role": "Migration engineer", "ratecard_role_basis": "exact rate-card role", "role": "Migration engineer", "seniority": "junior", "skills": ["Java 21", "Spring Boot 3", "JUnit 5"], "synthetic": true}, {"availability": [{"allocation_percent": 60, "from": "2026-11-09", "to": "2027-01-22"}], "id": "SE-08", "name": "Synthetic Engineer 08", "ratecard_role": "Migration engineer", "ratecard_role_basis": "nearest rate-card role; no dedicated database rate exists", "role": "Database engineer", "seniority": "senior", "skills": ["PostgreSQL", "Flyway", "Data migration", "Performance testing"], "synthetic": true}, {"availability": [{"allocation_percent": 50, "from": "2027-01-04", "to": "2027-02-26"}], "id": "SE-09", "name": "Synthetic Engineer 09", "ratecard_role": "Migration engineer", "ratecard_role_basis": "nearest rate-card role; no dedicated security rate exists", "role": "Security engineer", "seniority": "senior", "skills": ["Application security", "Spring Security", "Java 21", "Dependency scanning"], "synthetic": true}, {"availability": [{"allocation_percent": 100, "from": "2026-10-26", "to": "2027-03-31"}], "id": "SE-10", "name": "Synthetic Engineer 10", "ratecard_role": "Migration engineer", "ratecard_role_basis": "nearest rate-card role; no dedicated QA rate exists", "role": "QA automation engineer", "seniority": "mid", "skills": ["QA automation", "Playwright", "JUnit 5", "API testing"], "synthetic": true}, {"availability": [{"allocation_percent": 100, "from": "2026-11-02", "to": "2027-03-31"}], "id": "SE-11", "name": "Synthetic Engineer 11", "ratecard_role": "Migration engineer", "ratecard_role_basis": "nearest rate-card role; no dedicated QA rate exists", "role": "QA automation engineer", "seniority": "junior", "skills": ["QA automation", "Playwright", "Manual regression testing"], "synthetic": true}, {"availability": [{"allocation_percent": 50, "from": "2026-10-19", "to": "2027-03-31"}], "id": "SE-12", "name": "Synthetic Engineer 12", "ratecard_role": "Solution architect", "ratecard_role_basis": "nearest rate-card role; no dedicated delivery-management rate exists", "role": "Delivery manager", "seniority": "senior", "skills": ["Delivery management", "Finance domain (invoicing and GST)", "Stakeholder reporting", "Risk management"], "synthetic": true}], "synthetic": true}

    roster · roster availability from=2026-10-19, to=2027-03-31

    {"from": "2026-10-19", "people": [{"id": "SE-01", "name": "Synthetic Engineer 01", "ratecard_role": "Solution architect", "role": "Solution architect", "windows": [{"allocation_percent": 50, "from": "2026-10-26", "to": "2026-12-18"}, {"all…
    Full output
    {"from": "2026-10-19", "people": [{"id": "SE-01", "name": "Synthetic Engineer 01", "ratecard_role": "Solution architect", "role": "Solution architect", "windows": [{"allocation_percent": 50, "from": "2026-10-26", "to": "2026-12-18"}, {"allocation_percent": 100, "from": "2027-01-04", "to": "2027-03-31"}]}, {"id": "SE-02", "name": "Synthetic Engineer 02", "ratecard_role": "Solution architect", "role": "Solution architect", "windows": [{"allocation_percent": 100, "from": "2026-11-02", "to": "2027-02-26"}]}, {"id": "SE-03", "name": "Synthetic Engineer 03", "ratecard_role": "Migration engineer", "role": "Migration engineer", "windows": [{"allocation_percent": 100, "from": "2026-10-19", "to": "2027-01-29"}]}, {"id": "SE-04", "name": "Synthetic Engineer 04", "ratecard_role": "Migration engineer", "role": "Migration engineer", "windows": [{"allocation_percent": 80, "from": "2026-11-16", "to": "2027-03-31"}]}, {"id": "SE-05", "name": "Synthetic Engineer 05", "ratecard_role": "Migration engineer", "role": "Migration engineer", "windows": [{"allocation_percent": 100, "from": "2026-10-19", "to": "2026-12-11"}, {"allocation_percent": 100, "from": "2027-01-11", "to": "2027-03-31"}]}, {"id": "SE-06", "name": "Synthetic Engineer 06", "ratecard_role": "Migration engineer", "role": "Migration engineer", "windows": [{"allocation_percent": 100, "from": "2026-12-01", "to": "2027-03-31"}]}, {"id": "SE-07", "name": "Synthetic Engineer 07", "ratecard_role": "Migration engineer", "role": "Migration engineer", "windows": [{"allocation_percent": 100, "from": "2026-10-19", "to": "2027-03-31"}]}, {"id": "SE-08", "name": "Synthetic Engineer 08", "ratecard_role": "Migration engineer", "role": "Database engineer", "windows": [{"allocation_percent": 60, "from": "2026-11-09", "to": "2027-01-22"}]}, {"id": "SE-09", "name": "Synthetic Engineer 09", "ratecard_role": "Migration engineer", "role": "Security engineer", "windows": [{"allocation_percent": 50, "from": "2027-01-04", "to": "2027-02-26"}]}, {"id": "SE-10", "name": "Synthetic Engineer 10", "ratecard_role": "Migration engineer", "role": "QA automation engineer", "windows": [{"allocation_percent": 100, "from": "2026-10-26", "to": "2027-03-31"}]}, {"id": "SE-11", "name": "Synthetic Engineer 11", "ratecard_role": "Migration engineer", "role": "QA automation engineer", "windows": [{"allocation_percent": 100, "from": "2026-11-02", "to": "2027-03-31"}]}, {"id": "SE-12", "name": "Synthetic Engineer 12", "ratecard_role": "Solution architect", "role": "Delivery manager", "windows": [{"allocation_percent": 50, "from": "2026-10-19", "to": "2027-03-31"}]}], "synthetic": true, "to": "2027-03-31"}
  2. 2

    Bob lays the work out in waves. Fixes for the defects come first, then the pilot, then the wider forecast, with project management and testing running alongside.

    WBS ID assignment plan: WBS-1.1 through WBS-1.7: Wave 1 — Prerequisite remediation (findings-driven, pre-pilot blockers) WBS-2.1, WBS-2.2, WBS-2.3: Wave 2 — Java invoice-preview pilot (proposed, separately gated) WBS-3.1 through WBS-3.4: Wave 3 — Enterprise forecast (planning only) WBS-4.1, WBS-4.2: Cross-cutting PM and QA
  3. 3

    Bob turns the injection defect into a work package. The fix becomes the first package of the plan, with its owner, its length and the test that says it is done.

    read file reports/plan-tasks.json

    7 |       "wbs_id": "WBS-1.1",
    8 |       "deliverable_id": "DEL-001",
    9 |       "finding_ids": ["F-002"],
    10 |       "owner_role": "Migration engineer (security)",
    13 |       "duration_days": "3",
    14 |       "duration_basis": "Assumed 1 migration engineer; 2 queries rewritten with PreparedStatement + DeskMoneyTest SQL injection regression test; elapsed days illustrative pending client calendar.",
    All 50 lines
    1 | {
    2 |   "schema_version": 1,
    [line hidden: an internal run reference]
    [line hidden: an internal run reference]
    5 |   "tasks": [
    6 |     {
    7 |       "wbs_id": "WBS-1.1",
    8 |       "deliverable_id": "DEL-001",
    9 |       "finding_ids": ["F-002"],
    10 |       "owner_role": "Migration engineer (security)",
    11 |       "predecessors": [],
    12 |       "client_input": "Client security owner authorization for parameterized query changes; confirmed network exposure posture (DEC-002, DEC-003).",
    13 |       "duration_days": "3",
    14 |       "duration_basis": "Assumed 1 migration engineer; 2 queries rewritten with PreparedStatement + DeskMoneyTest SQL injection regression test; elapsed days illustrative pending client calendar.",
    15 |       "exit_evidence": "Code review sign-off; DeskMoneyTest (with SQL injection regression test) passing on target branch; senior reviewer approval.",
    16 |       "status": "planned"
    17 |     },
    18 |     {
    19 |       "wbs_id": "WBS-1.2",
    20 |       "deliverable_id": "DEL-002",
    21 |       "finding_ids": ["F-003"],
    22 |       "owner_role": "Migration engineer",
    23 |       "predecessors": [],
    24 |       "client_input": "Client finance owner written decision on discount boundary rule (>= 100 or > 100); authorization for historical data audit scope (DEC-001).",
    25 |       "duration_days": "2",
    26 |       "duration_basis": "Assumed 1 migration engineer; aligns bookedLineNet boundary to client-confirmed rule; adds parameterized unit tests for qty=99/100/101 in both preview and save paths. Client decision is the gate; elapsed days illustrative.",
    27 |       "exit_evidence": "Written client business rule decision (DEC-001); parameterized unit test passing for qty boundary; code reviewed; senior reviewer sign-off.",
    28 |       "status": "blocked — DEC-001 pending"
    29 |     },
    30 |     {
    31 |       "wbs_id": "WBS-1.3",
    32 |       "deliverable_id": "DEL-003",
    33 |       "finding_ids": ["F-010", "F-005"],
    34 |       "owner_role": "Migration engineer",
    35 |       "predecessors": ["WBS-1.1", "WBS-1.2"],
    36 |       "client_input": "Client technical lead authorization for Spring Boot 3.x upgrade; CVE scan result (DEC-002); confirmed Java 21 as target runtime (DEC-007).",
    37 |       "duration_days": "4",
    38 |       "duration_basis": "Assumed 1–2 migration engineers; pom.xml parent bump to Spring Boot 3.x; javax→jakarta namespace check; Class.newInstance() replacement; DeskMoneyTest passing under new build. Elapsed days illustrative.",
    39 |       "exit_evidence": "Clean Maven build under Spring Boot 3.x and Java 21; DeskMoneyTest green; no unresolved javax.* imports; Class.getDeclaredConstructor().newInstance() in place; code reviewed; tech lead sign-off.",
    40 |       "status": "planned"
    41 |     },
    42 |     {
    43 |       "wbs_id": "WBS-1.4",
    44 |       "deliverable_id": "DEL-004",
    45 |       "finding_ids": ["F-006", "F-007"],
    46 |       "owner_role": "Migration engineer (database)",
    47 |       "predecessors": ["WBS-1.3"],
    48 |       "client_input": "Client technical lead confirmation of Flyway introduction; DEC-004 (backup/DR posture for H2) before data migration stage.",
    49 |       "duration_days": "3",
    50 |       "duration_basis": "Assumed 1 database engineer; Flyway V1 baseline migration script extracted from Desk.setup DDL; FORMATDATETIME→TO_CHAR rewrite for monthly and annual methods; integration test against PostgreSQL dialect. Elapsed days illustrative.",
  4. 4

    Bob writes a plan a delivery team can staff. The plan covers staffing, team responsibilities and the critical order of work, which is what a partner needs before quoting.

    grep reports/implementation-plan.md

    reports/implementation-plan.md:
      Line 32: ## Delivery waves and work packages
      Line 110: ## Staffing and skills coverage
      Line 137: ## Velocity calibration protocol
      Line 155: ## Team, responsibilities and sign-offs
      Line 172: ## Critical dependency chain
    All 13 lines
    Found 11 matches
    reports/implementation-plan.md:
      Line 16: ## Scope and source of effort
      Line 32: ## Delivery waves and work packages
      Line 87: ## Work packages by component
      Line 110: ## Staffing and skills coverage
      Line 137: ## Velocity calibration protocol
      Line 155: ## Team, responsibilities and sign-offs
      Line 172: ## Critical dependency chain
      Line 188: ## Prerequisite delay review
      Line 199: ## Acceptance, responsibilities and client inputs
      Line 221: ## Delivery controls and stakeholder visibility
      Line 234: ## Risks, exclusions and change control

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: how Bob estimates, who could staff each package, Wave 1 in detail, the critical path, delays, exit rule and rollback

How Bob estimates, in his words

Estimate method: Assessment-forecast. Hours per row are total person-hours for that deliverable, derived from plan elapsed-day durations (from implementation-plan.md) and per-role productivity assumptions (developer: 5–10 h/day central 7h; reviewer/tech lead/QA: 4–8 h/day central 6h; PM facilitation: lighter personal hours relative to elapsed-day window). No measured pilot data is available. The estimate is unpriced and should not be used for client financial commitment without an authorized rate card.

Who could staff each package, in Bob's words

The people are the synthetic team (see the team); this table numbers the packages its own way: WBS-1.1 is DEL-001, the fix for line 93, but the other numbers do not match the effort model's.

WBSRequired skillsNamed synthetic people (SE-NN)Availability windowSkills gap
WBS-1.1Java 8/21, Spring Boot 2/3, Application security, JUnit 5SE-01 Synthetic Engineer 01 (Solution architect, Application security), SE-03 Synthetic Engineer 03 (Migration engineer, Java 8/21, Spring Boot 2/3)SE-01: 50% from 2026-10-26; SE-03: 100% from 2026-10-19SE-09 Synthetic Engineer 09 (Security engineer, Dependency scanning) available 50% from 2027-01-04 — not available in Wave 1 window; skills gap: dedicated security engineer unavailable in Wave 1; SE-01 covers Application security at 50% allocation
WBS-1.2Java 8/21, Spring Boot 2/3, JUnit 5SE-03 Synthetic Engineer 03, SE-05 Synthetic Engineer 05SE-03: 100% from 2026-10-19; SE-05: 100% from 2026-10-19skills gap: none found
WBS-1.3Java 8/21, Spring Boot 2/3, MavenSE-03 Synthetic Engineer 03, SE-05 Synthetic Engineer 05, SE-07 Synthetic Engineer 07SE-03: 100% to 2027-01-29; SE-05: 100% to 2026-12-11; SE-07: 100% from 2026-10-19skills gap: none found
WBS-1.4Java 21, Spring Boot 3, PostgreSQL, FlywaySE-04 Synthetic Engineer 04, SE-08 Synthetic Engineer 08SE-04: 80% from 2026-11-16; SE-08: 60% from 2026-11-09SE-04 available from 2026-11-16 (not 2026-10-19); WBS-1.4 predecessors on WBS-1.3 so window is acceptable. SE-08 database engineer available 60%. skills gap: none found for window
WBS-1.5Java 21, Spring Boot 3, JUnit 5SE-03 Synthetic Engineer 03, SE-04 Synthetic Engineer 04, SE-07 Synthetic Engineer 07SE-03: 100% from 2026-10-19; SE-04: 80% from 2026-11-16; SE-07: 100% from 2026-10-19skills gap: none found
WBS-1.6QA automation, JUnit 5, API testingSE-10 Synthetic Engineer 10, SE-11 Synthetic Engineer 11SE-10: 100% from 2026-10-26; SE-11: 100% from 2026-11-02skills gap: none found
WBS-1.7Solution architecture, Finance domain (invoicing and GST), Delivery managementSE-01 Synthetic Engineer 01, SE-12 Synthetic Engineer 12SE-01: 50% from 2026-10-26; SE-12: 50% from 2026-10-19skills gap: SE-01 and SE-12 both at partial allocation; confirm sufficient capacity with resourcing owner
WBS-2.1Solution architecture, Java 21, Spring Boot 3SE-01 Synthetic Engineer 01, SE-02 Synthetic Engineer 02SE-01: 50% from 2026-10-26, 100% from 2027-01-04; SE-02: 100% from 2026-11-02[held back]
WBS-2.2Java 21, Spring Boot 3, Maven, JUnit 5SE-03 Synthetic Engineer 03, SE-05 Synthetic Engineer 05SE-03: 100% to 2027-01-29; SE-05: 100% from 2027-01-11skills gap: none found — PROPOSED NOT AUTHORIZED
WBS-2.3QA automation, API testingSE-10 Synthetic Engineer 10, SE-11 Synthetic Engineer 11SE-10: 100% from 2026-10-26; SE-11: 100% from 2026-11-02skills gap: none found — PROPOSED NOT AUTHORIZED
WBS-3.1PostgreSQL, Flyway, Data migration, Java 21, Spring Boot 3SE-04 Synthetic Engineer 04, SE-06 Synthetic Engineer 06, SE-08 Synthetic Engineer 08SE-04: 80% from 2026-11-16; SE-06: 100% from 2026-12-01; SE-08: 60% from 2026-11-09 to 2027-01-22SE-08 availability ends 2027-01-22 — FORECAST; resourcing owner must confirm window alignment
WBS-3.2Java 21, Spring Boot 3, MavenSE-05 Synthetic Engineer 05, SE-07 Synthetic Engineer 07SE-05: 100% from 2027-01-11; SE-07: 100% from 2026-10-19skills gap: TypeScript skill not held by any synthetic roster person — skills gap identified; resourcing owner must source TypeScript capability before WBS-3.2
WBS-3.3Java 21, Spring Boot 3, JUnit 5SE-07 Synthetic Engineer 07SE-07: 100% from 2026-10-19skills gap: none found for Java remediation; Spring Batch expertise not confirmed in roster — skills gap: Spring Batch expertise; resourcing owner to assess
WBS-3.4Solution architecture, Java 21, Spring Boot 3SE-01 Synthetic Engineer 01, SE-02 Synthetic Engineer 02SE-01: 100% from 2027-01-04; SE-02: 100% from 2026-11-02 to 2027-02-26skills gap: none found
WBS-4.1Delivery management, Finance domain (invoicing and GST)SE-12 Synthetic Engineer 12SE-12: 50% from 2026-10-19skills gap: none found
WBS-4.2QA automation, API testing, JUnit 5SE-10 Synthetic Engineer 10, SE-11 Synthetic Engineer 11SE-10: 100% from 2026-10-26; SE-11: 100% from 2026-11-02skills gap: none found

Marked rows are where Bob names a skills gap. Our checks hold back a table cell, marked where it was.

Wave 1, package by package

PackageWhat it doesWhoHours: low / central / high
DEL-001SQL injection remediation — parameterize customers() and invoices() queriesSI developer15 / 21 / 30
DEL-002Security endpoint hardening — redact /api/health and add auth guard on /api/jobs/**SI developer10 / 14 / 20
DEL-003Test suite foundation — integration tests for routes, batch atomicity, discount boundary and job dispatchSI developer20 / 28 / 40
DEL-004Wave 1 independent review — security remediations and test suiteSI senior reviewer8 / 12 / 16
DEL-005Client decision gate — DEC-001 through DEC-006 and GAP-001 through GAP-003 closureSI PM8 / 16 / 24

Together they make Wave 1's 61 / 91 / 130 hours. Later waves are planned at this level too; only Wave 1 is shown here.

How DEL-001 was sized, in Bob's words

  • Method: 3 elapsed working days × 5–10 productive hours/day (central 7h); one developer
  • What could change it: F-001 remediation scope may widen if additional query builders are found in non-inspected paths (noted in findings); no runtime test performed; client source access confirmation required

The critical path

WBS-1.3 > 1.4 > 1.5 > 3.1 > 3.2 > 3.3 > (3.4 | 3.7 | 3.8 | 3.5) > 3.10 > 3.11 > 4.1 > 4.3

The longest chain of dependent work takes approximately 41 working days (single-resource model): that is, if one person did each step in turn.

What a slip in the client's decisions does

  • 5 days late: Wave 3 starts on day 19 instead of day 14.
  • 10 days late: Wave 3 starts on day 24.

When a package counts as done

No WBS exit is accepted on delivery team self-assessment alone; each requires either an independent review record (senior reviewer / tech lead / QA) or a written client decision.

Bob, implementation plan

Before the later waves can start

Human acceptance by client technical lead and operations lead required before Wave 2 or Wave 3 begins.

Bob, implementation plan

How to undo the Java step

Revert the java.version property and the Spring Boot parent version in pom.xml (pilot evidence must show the rollback route verified).

04

Commercial lead · prices the deal

Prove and price: does it work, and what does it cost?

Bob tests his recommendation on one slice, then his hours go into your own pricing tool, so the number on the SOW is yours, with the working shown.

Refactor a pilot slice

Bob tries the recommended path on one slice, the invoice preview on Java 21, and the checks show nothing else broke.

8 of 8checks on the new invoice preview passed

12tests pass on Java 21, with 0 failures

2 of 2browser journeys work, desktop and phone

  1. The pilot covers the invoice preview only; saving, the batch jobs and the reports stay on the old code.
  2. Nobody has yet accepted the pilot's results; that decision stays with the SI and the client.

Calibrate velocity

Once a pilot has run, its recorded hours adjust the estimate. Shown here on sample timesheets: 9.5% over plan.

  1. WP-0140 → 42.5
  2. WP-0264 → 72.0
  3. WP-0332 → 34.5
  4. WP-0416 → 17.5

The timesheet tool's four sample pilot packages: planned (light) and actual (dark). In all, 152 hours planned and 166.5 worked.

  1. The timesheets are synthetic, from the timesheet tool's own sample pilot, not the Java 21 pilot above: this shows the method, not a real team's speed. See the timesheets.
  2. The SI's pricing tool, not Bob, applies that ratio to the rest of the work.

Price SOW and risk buffer

Bob's hours go into the SI's own pricing tool, so the price is the SI's.

The package we follow · DEL-001, the fix for line 93

Low SGD 2,100

Central SGD 2,940

High SGD 4,200

Central: 21 h × SGD 140 an hour

The whole deal: SGD 61,870 central from Bob's hours, with a flat 15% allowance as the risk buffer.

Synthetic illustration on a demonstration SGD rate card — not a quote.

  1. Bob's own statement of work leaves the price blank: Not priced — no authorized rate card.
  2. The rates, and which rate each role is charged at: see the rate card.
  3. After the pilot calibration, DEL-001 comes to SGD 3,220.
Go deeper: the whole deal, three ways to sell it, the rate card, the discount check, and what the SOW leaves out

The whole deal

LowCentralHigh
From Bob's hoursSGD 42,872SGD 61,870SGD 87,400
After the pilot calibrationSGD 55,326SGD 67,774SGD 77,590

Totals excluding tax. Each includes a flat 15% allowance (SGD 8,070 on the first central figure). The allowance is the risk buffer here: a flat rate, not a risk-by-risk contingency.

The same work, three ways to sell it

Fixed price

SGD 67,774

Bob's central hours for Waves 1, 3 and 4 after calibration, plus the flat 15% allowance, as one price. The SI carries the risk of an overrun.

Capped time and materials

SGD 58,934

Estimated, billed as worked, capped at SGD 67,470. The same calibrated hours with no allowance on top; the cap adds headroom, so it sits just under the fixed price. The client pays for hours worked up to the cap; the SI carries anything beyond it.

Pilot, then programme

SGD 91,964

The pilot sold first, at the 166.5 hours the sample timesheets record, then the programme as the fixed price above. The fixed price does not include the pilot, so this is the two added together. The client commits to the programme only after the pilot has shown how fast the work goes.

Synthetic illustration on a demonstration SGD rate card — not a quote.

From hours to price, line by line

Rate-card role (synthetic rates)Plan roles it coversHours: low / central / highRate per hour
Migration engineerSI developer, SI QA202 / 284 / 404SGD 140.00
Solution architectSI PM, SI tech lead, SI senior reviewer50 / 78 / 108SGD 180.00

Central: hours times rate for each role, added up, gives a subtotal of SGD 53,800.00; the flat allowance is added once on top. A risk-by-risk contingency is not available: Bob's statement of work has no table of priced risks, so the tool uses the flat allowance and says so.

The discount boundary, checked on the new preview

Bob's finding F-004 from Assess: Preview/save bulk-discount off-by-one: qty=100 discounted in preview but not in save. Checked on the new preview: the total for 99, 100 and 101 units.
Quantity99100101
Preview total1025.151014.791024.94

How Bob's SOW sizes the work

Developer: 5–10 h/day central 7h; reviewer/tech lead/QA: 4–8 h/day central 6h. All hours are assessment-forecasts derived from plan elapsed-day durations, role productivity assumptions, and finding scope. No actual pilot hours exist.

What Bob's SOW excludes

  • Kotlin migration
  • Full microservice decomposition beyond the gated pilot
  • Security DAST/SAST execution (client-owned)
  • Performance and load testing
  • Production deployment, infrastructure provisioning, cloud configuration, CI/CD pipeline setup

What the client must supply

  • Technical and security owner: target selection (DEC-001), InvoiceRunJobV2 disposition (DEC-004), security filter confirmation (DEC-005)
  • Business owner: discount boundary at qty=100 (DEC-002), credit approval workflow (DEC-006)
  • Operations and data owner: backup/DR policy (DEC-003), schedule setting (GAP-001), data volume and SLA (GAP-003)
  • Security owner: SAST / CVE scanner output (GAP-002)

How the work is accepted

Each deliverable exit is a named test ID (T-NNN) referencing a specific observable artifact. No deliverable exit is accepted on delivery-team self-assessment alone. Acceptance status: Pending.

What triggers a change request

  • Migration target profile (DEC-001)
  • Adding any excluded scope
  • Additional security findings expanding Wave 1 or the critical path

The thread through all four stages

One line of code, followed all the way to a price

Discover Assess Plan Prove andprice Ingest repos securely Map dependencies Line 93Bob's evidence, EV-007 Score tech debt F-001rated critical Pick modernisation paths Break into packages DEL-00121 h central (15–30) Map skills to team Refactor a pilot slice Calibrate velocity Pricedin the SI's own tool 1 2 3 4
  1. DiscoverLine 93, Bob's evidence EV-007Whatever is typed in the search box is glued into the query.
  2. AssessFinding F-001, rated criticalSQL injection via string-concatenated LIKE clauses in customers() and invoices()
  3. PlanWork package DEL-001low 15, central 21, high 30 hours, with a test that says when it is done.
  4. Prove and pricePriced in the SI's own toolFrom Bob's central hours.

The fix for line 93, priced, in SGD

Low 2,100

Central 2,940

High 4,200

Priced from Bob's hours, before the pilot calibration (after it, central SGD 3,220). Synthetic illustration on a demonstration SGD rate card — not a quote.

The price for fixing line 93 can be followed back, step by step, to that line of code.

What the SI walks away with

The four documents Bob hands over

Each one is Bob's own text, unchanged, and opens here; the few sentences our checks hold back are marked where they were.

  • Assessment report

    What is wrong, how bad it is and which way to go, with Bob's annexes on the seven migration paths.

    Read the document
  • Effort model

    Every work package in hours, as a range, with what could change it.

    Read the document
  • Implementation plan

    The waves, the work packages in order, who does them and what must be true before each starts.

    Read the document
  • Statement of work

    Scope, deliverables, client dependencies, acceptance and change control.

    Read the document

The sample data

The made-up data the demo runs on, so you can see what Bob and the SI's tools worked from. All synthetic.

  • The team

    Twelve made-up people: roles, skills and when they are free. Bob staffs the plan from them.

    Open the data
  • The timesheets

    A sample pilot's planned packages and the time logged against them, used to show the calibration.

    Open the data
  • The rate card

    The hourly rates the SI's pricing tool charges, and which rate each role is charged at.

    Open the data

Want to run it on your own copy? Try it yourself

Part 2

Go further

For reading at your own pace.

Bob's range

Seven small migrations, and how each was checked

For this demo we asked Bob for all seven paths his assessment weighed, each the same small feature, the invoice preview, to show his range. The Java 21 one is the pilot in Prove and price (its walkthrough replays a later session).

  • The pilot

    Java 21invoice-preview sidecar on Java 21 behind the Java 8 app

    Tests: 13 passed, 0 failed (12 on the new service, 1 on the old app)

    Connected and checked

    The assessment said: proceed with Java 21 upgrade as the first mandatory step of the bounded migration path (Option 2)

  • Kotlininvoice-preview server rebuilt in Kotlin

    Tests: 17 passed, 0 failed

    Built and tested on its own

    The assessment said: not recommended as the primary migration path at this time; viable long term after the bounded migration

  • TypeScriptbrowser side of invoice preview moved to TypeScript

    Tests: 42 passed, 0 failed

    Built and tested on its own

    The assessment said: low-risk quality improvement, recommended as a parallel track

  • PostgreSQLcustomer and product reference data for invoice preview moved from H2 to PostgreSQL

    Built: the PostgreSQL tables and their data, as one script.

    Script only, not connected to the app

    The assessment said: required for any production deployment; highest-impact structural change; after the Java 21 / Spring Boot 3 upgrade

  • Microserviceinvoice preview pulled out as its own Spring Boot service on Java 21

    Tests: 43 passed, 0 failed

    Built and tested on its own

    The assessment said: viable future option, not a recommended immediate action

  • Python (FastAPI)invoice preview rebuilt as a FastAPI service

    Tests: 36 passed, 0 failed

    Built and tested on its own

    The assessment said: not recommended as the primary path; viable for a greenfield preview service; full rewrite

  • .NET (ASP.NET Core)invoice preview rebuilt as an ASP.NET Core minimal API

    Tests: 32 passed, 0 failed

    Built and tested on its own

    The assessment said: not recommended as the primary path; viable if the client standard is .NET; full rewrite

  • Each card shows what Bob's assessment said about that path before anything was built, then the tests on what was built, and how far it got.

The tests are Bob's own, on the code he wrote. Only the Java 21 service is connected to the old app. The other five targets were built and tested on their own, and the PostgreSQL script is not connected to the app: it was loaded into a test PostgreSQL database when it was made, but not when this repository was put together. We ran each target's tests when this repository was put together; each walkthrough shows the output word for word, and what a reviewer would still raise. Every target's code is in the repository's targets/ folder.

Under the hood

How it is built

IBM Bob platform: SaaS in this demo (self-hosted or on-premises not shown).

Context sources

  • Git repoa frozen, allowlisted copy of the application
  • Ticketsvia a read-only MCP server, synthetic data
  • Roster, rate card, timesheetsvia read-only MCP servers, synthetic data
  • IBM i librariesRPG, CL, DDSNot shown in this demo
  • Database schemasDb2, Oracle, SQL ServerNot shown in this demo
  • Docs and wikiConfluence, SharePointNot shown in this demo
  • CI/CDJenkins and similarNot shown in this demo

Interfaces

  • Bob Shellbatch runs, one command per stage
  • Bob IDEinteractive; the 'Try it yourself' pathNot shown in this demo

Agent modes

  • Orchestrator and subagentshands a read-only task to a subagent
  • Custom modesnine SI personas, each allowed to write only its own files
  • Code modethe pilot refactor and seven migrations
  • MCP tool accessfour read-only servers

Context and guidance

  • Rulesthe SI's standards and scoring rubric
  • Skillsone workflow per stage
  • Commands/discover, /assess, /plan, /price

Models and trust

  • Guardrailswrites kept to the run folder, read-only tools, secrets kept out
  • Findings with evidenceevery finding cites source lines
  • Checksthe SI's checkers re-verify every figure and link
  • Recorded sessionsevery Bob session is recorded and replayable
  • Model routingGranite and frontier modelsNot shown in this demo

What Bob hands over

  • Assessment reportfindings, debt scores, modernisation options
  • Effort modellow, central and high hours per work package
  • Implementation planwork packages, waves, staffing
  • SOWdraft, unpriced: the SI prices it in its own tool
  • Architecture diagramsdrawn by Bob as Mermaid

In this demoNot shown in this demo

Go deeper: the partner kit, and what each command costs to run

The partner kit

  • 9 persona modes, from orchestrator to QA, each allowed to write only its own files
  • Four commands: /discover, /assess, /plan, /price
  • Four read-only tools: roster, rate card, tickets and timesheets, all with synthetic data
  • Bob writes only inside runs/<name>/
  • custom SI workflows on vanilla IBM Bob; not an IBM premium package
  • Follow one command through the kit: what happens when you run /assess

What each command costs to run, in Bob credits

  • /discover: about 0.4 to 0.5
  • /assess: about 3
  • /plan: about 3.2
  • /price: about 2.5 to 3.3
  • All four: about 9 to 10

Bob credits (Bobcoins) are IBM's usage charge for running Bob. They are not part of the SI's price to the client.

In money: IBM publishes $0.50 per Bobcoin for extra coins and Enterprise packs (checked 19 October 2026), so all four commands come to about US$4.50 to US$5. Monthly plans include coins at a lower effective rate.

Your turn

Try it yourself

Open the Bob in action repository in your own Bob, choose a run name, and run the four commands in order. After each one you run a plain checker yourself.

/discover acme-1

/assess acme-1

/plan acme-1

/price acme-1

Questions? Reach out to us.

Back to the seven migrations

Migration walkthrough · one feature, not a full migration

Java 8 to Java 21

What moved: invoice-preview sidecar on Java 21 behind the Java 8 app.

What the assessment said, before anything was built

proceed with Java 21 upgrade as the first mandatory step of the bounded migration path (Option 2)

A judgement from reading the code: no trial build was run at assessment.

How it was checked

This one went furthest. Beyond Bob's own tests, the pilot was checked from the outside:

  • API checks passed: 8 of 8, including quantity 99, 100 and 101 either side of the discount boundary, and a preview that writes nothing.
  • With the new service stopped, the old app answers HTTP 502, an error rather than a total.
  • Browser journeys: 2 of 2, on desktop and phone.
  • Rolled back: preview total 1014.79 and saved invoice 528.65 / 528.65 before and after.
  • Workflows outside the pilot still work: saving, the invoice and credit-note jobs, the monthly and annual GST reports.

Human acceptance for production: pending.

Before and after

Before Try the legacy app

  • Browser the Invoice Desk screen
  • Java 8 app works out the preview in Desk.calculate()
  • H2 database customers, their price tiers and products

In the code that moved, Bob's assessment found:

  • F-004 high Preview/save bulk-discount off-by-one: qty=100 discounted in preview but not in save Kept on purpose: the preview still discounts at 100 items, so preview and save still disagree at exactly 100.
  • F-007 low 50-line invoice limit enforced server-side only; browser UI provides no feedback Still checked only on the server; the screen is unchanged.
  • F-011 low GST rate hardcoded in two locations — constant duplication Now in three places: the new service has its own copy of the GST rate.

After Connected and checked

  • Browser unchanged
  • Java 8 app looks up the customer's tier, then sends the preview on
  • New, under the Java 8 app: Java 21 service the preview rule, moved unchanged the old app sends every preview to it
  • H2 database unchanged
Our drawing, from the code in targets/java/.

Same order, both answers

The three orders you can try on the legacy app, sent to it and to this target on our machine on 23 October 2026. Every figure is what the program answered.

OrderThe legacy app POST /api/invoices/previewJava 21 service behind the old app POST /api/invoices/previewVerdict
Aster Office Supplies100 × PAPER-A4931.00 + GST 83.79 = 1014.79931.00 + GST 83.79 = 1014.79Same figures, no customer name
Merlion Retail10 × TONER-BK485.00 + GST 43.65 = 528.65485.00 + GST 43.65 = 528.65Same figures, no customer name
Straits Workshop3 × CABLE-USB, 101 × PAPER-A4999.81 + GST 89.98 = 1089.79999.81 + GST 89.98 = 1089.79Same figures, no customer name

The old app still takes the order at its own address and passes it to the Java 21 service, which works out the figures. They match for all three orders; the answer no longer carries the customer's name.

The legacy preview rule, moved unchanged into a new Java 21 service. The old app now sends preview requests to it; everything else stays where it was.

Before the legacy app · app/src/main/java/com/ingramtrials/base/Desk.java
BigDecimal previewLineNet(String tier, int qty, BigDecimal price) {
    BigDecimal net=price.multiply(BigDecimal.valueOf(qty));
    if ("A".equals(tier)) net=net.multiply(new BigDecimal("0.95"));
    if ("B".equals(tier)) net=net.multiply(new BigDecimal("0.97"));
    if (qty>=100) net=net.multiply(new BigDecimal("0.98"));
    return net.setScale(2,RoundingMode.HALF_UP);
}
After written by Bob · targets/java/preview-sidecar/src/main/java/com/ingramtrials/preview/PreviewController.java
// qty>=100 triggers the bulk discount (preview boundary: inclusive at 100)
BigDecimal previewLineNet(String tier, int qty, BigDecimal price) {
    BigDecimal net = price.multiply(BigDecimal.valueOf(qty));
    if ("A".equals(tier)) net = net.multiply(new BigDecimal("0.95"));
    if ("B".equals(tier)) net = net.multiply(new BigDecimal("0.97"));
    if (qty >= 100) net = net.multiply(new BigDecimal("0.98"));
    return net.setScale(2, RoundingMode.HALF_UP);
}

Which version is this? In this checked version the old app looks up the customer's price tier and passes it to the new service. In a later session, replayed below, Bob moved that lookup into the new service itself, as the other six targets do. The checks above were run on the earlier version, which is the one in this repository.

What a reviewer would raise. Two things in this checked version that the checks above did not look for, because they compared totals and errors. The new service's answer leaves out the customer's name, so the preview panel shows "undefined" where the name should be; the total is right. And the old app refused a product code it did not know, while the new path prices any product code sent to it, even one that does not exist. A reviewer would return the name with the preview and check the product exists in the database before working out the total. Bob's later session, replayed below, returns the name and refuses unknown codes; that version was not put through these checks.

Bob at work

From the later recorded Bob session, the one that moved the price-tier lookup into the new service, trimmed to four moments. Bob worked from a written brief that listed what the code must do; the moments show how he carried it out. Our commentary is on the left.

  1. 1

    Bob reads the jobs his change must leave alone. The old application also runs invoice and credit-note jobs on a schedule. He reads them so that moving invoice preview does not disturb them.

    Let me now read the remaining app source files quickly:

    read file targets/java/app/src/main/java/com/ingramtrials/base/ScheduledJobs.java

    8 | public class ScheduledJobs {
    12 |     @Scheduled(cron="${demo.schedule.invoice-cron:0 0 1 * * *}")
    13 |     public void invoiceRun() throws Exception { if (enabled) desk.runJob("invoices"); }
    14 |     @Scheduled(cron="${demo.schedule.credit-cron:0 30 1 * * *}")
    15 |     public void creditRun() throws Exception { if (enabled) desk.runJob("credit-notes"); }
    All 17 lines
    1 | package com.ingramtrials.base;
    2 | 
    3 | import org.springframework.beans.factory.annotation.Value;
    4 | import org.springframework.scheduling.annotation.Scheduled;
    5 | import org.springframework.stereotype.Component;
    6 | 
    7 | @Component
    8 | public class ScheduledJobs {
    9 |     final Desk desk;
    10 |     @Value("${demo.schedule.enabled:false}") boolean enabled;
    11 |     ScheduledJobs(Desk desk) { this.desk=desk; }
    12 |     @Scheduled(cron="${demo.schedule.invoice-cron:0 0 1 * * *}")
    13 |     public void invoiceRun() throws Exception { if (enabled) desk.runJob("invoices"); }
    14 |     @Scheduled(cron="${demo.schedule.credit-cron:0 30 1 * * *}")
    15 |     public void creditRun() throws Exception { if (enabled) desk.runJob("credit-notes"); }
    16 | }
    17 | 

    read file targets/java/app/src/main/java/com/ingramtrials/base/CreditNoteJob.java

    3 | public class CreditNoteJob implements Job {
    4 |     public int execute(Desk desk) {
    5 |         return desk.creditBatch();
    All 8 lines
    1 | package com.ingramtrials.base;
    2 | 
    3 | public class CreditNoteJob implements Job {
    4 |     public int execute(Desk desk) {
    5 |         return desk.creditBatch();
    6 |     }
    7 | }
    8 | 
  2. 2

    Bob lists what the tests must cover. Before writing the tests he sets out each case, including the one the brief names: a caller who sends a tier that does not match the customer.

    Tests (`PreviewControllerTest.java`) Must add: no-tier payload, mismatched injected tier, unknown customer/product, exact response fields. Must update existing tests that currently pass tier explicitly.
    Now rewrite the PreviewControllerTest.java to cover all required cases — no-tier payload, mismatched injected tier, unknown customer/product, exact response fields, plus preserving all existing passing tests:
  3. 3

    Bob checks by hand that the caller cannot choose the tier. He works the sums for that case himself, so the test expects the customer's real tier and not the one the caller asked for.

    The callerSuppliedTierIsIgnored case: customer 1 tier A, PAPER-A4 qty=1 unitPrice=10.00. net = 10 × 1 × 0.95 = 9.50 ✓ gst = 9.50 × 0.09 = 0.855 → 0.86 ✓ (HALF_UP: 0.855 rounds to 0.86 since 5 rounds up) total = 9.50 + 0.86 = 10.36 ✓
  4. 4

    Bob keeps the old application's own test. The old application has a test that pins how money is rounded. He judges that it still applies and leaves it unchanged.

    read file targets/java/app/src/test/java/com/ingramtrials/base/DeskMoneyTest.java

    7 | class DeskMoneyTest {
    8 |     @Test
    9 |     void moneyUsesTwoPlaces() {
    10 |         assertEquals("1.01", Desk.money(new BigDecimal("1.005")));
    All 13 lines
    1 | package com.ingramtrials.base;
    2 | 
    3 | import java.math.BigDecimal;
    4 | import org.junit.jupiter.api.Test;
    5 | import static org.junit.jupiter.api.Assertions.assertEquals;
    6 | 
    7 | class DeskMoneyTest {
    8 |     @Test
    9 |     void moneyUsesTwoPlaces() {
    10 |         assertEquals("1.01", Desk.money(new BigDecimal("1.005")));
    11 |     }
    12 | }
    13 | 
    Now update the DeskMoneyTest.java — it needs no changes.

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: build and test it yourself

From the repository root

mvn -B -f targets/java/preview-sidecar/pom.xml verify
mvn -B -f targets/java/app/pom.xml verify

The code and its build notes are in targets/java/.

Back to the seven migrations

Migration walkthrough · one feature, not a full migration

Kotlin

What moved: invoice-preview server rebuilt in Kotlin.

What the assessment said, before anything was built

not recommended as the primary migration path at this time; viable long term after the bounded migration

A judgement from reading the code: no trial build was run at assessment.

How it was checked

Bob wrote the code and its tests, but had no way to run them in his session. We ran them when this repository was put together: 17 passed, 0 failed (kotlinc 2.4.21).

These are Bob's own tests. They show the feature gives the legacy answers on the cases Bob wrote; they do not prove every case.

Before and after

Before Try the legacy app

  • Browser the Invoice Desk screen
  • Java 8 app works out the preview in Desk.calculate()
  • H2 database customers, their price tiers and products

In the code that moved, Bob's assessment found:

  • F-004 high Preview/save bulk-discount off-by-one: qty=100 discounted in preview but not in save Kept: the service still discounts at 100 items, and its tests check 99 and 100.
  • F-007 low 50-line invoice limit enforced server-side only; browser UI provides no feedback Not just unchecked in the browser now: the service has no line limit at all.

After Built and tested on its own

  • Browser unchanged
  • Java 8 app unchanged
  • New, under the Java 8 app: Kotlin service its own copy of the customers and two products not connected in this demo
  • H2 database unchanged

To connect it: The old app or the browser would call /api/invoice-preview in place of its own preview, and the service would read customers and products from the database in place of its built-in copy.

Our drawing, from the code in targets/kotlin/.

Same order, both answers

The three orders you can try on the legacy app, sent to it and to this target on our machine on 23 October 2026. Every figure is what the program answered.

OrderThe legacy app POST /api/invoices/previewThe Kotlin service POST /api/invoice-previewVerdict
Aster Office Supplies100 × PAPER-A4931.00 + GST 83.79 = 1014.79931.00 + GST 83.79 = 1014.79Same answer
Merlion Retail10 × TONER-BK485.00 + GST 43.65 = 528.65Refused: HTTP 404, “Unknown productCode: TONER-BK”Different: refused
Straits Workshop3 × CABLE-USB, 101 × PAPER-A4999.81 + GST 89.98 = 1089.79999.81 + GST 89.98 = 1089.79Same answer

The same answer, field for field, for the two orders it can price. It refuses the third: it knows only two of the three products.

The same rule written in Kotlin: tier discount, then the bulk discount from quantity 100, rounded half up.

Before the legacy app · app/src/main/java/com/ingramtrials/base/Desk.java
BigDecimal previewLineNet(String tier, int qty, BigDecimal price) {
    BigDecimal net=price.multiply(BigDecimal.valueOf(qty));
    if ("A".equals(tier)) net=net.multiply(new BigDecimal("0.95"));
    if ("B".equals(tier)) net=net.multiply(new BigDecimal("0.97"));
    if (qty>=100) net=net.multiply(new BigDecimal("0.98"));
    return net.setScale(2,RoundingMode.HALF_UP);
}
After written by Bob · targets/kotlin/src/main/kotlin/PreviewServer.kt
val gross = line.unitPrice.multiply(BigDecimal(line.qty))
val afterTier = gross.multiply(tierFactor)
val afterBulk = if (line.qty >= BULK_THRESHOLD) {
    afterTier.multiply(ONE.subtract(BULK_DISCOUNT))
} else {
    afterTier
}
val lineNet = afterBulk.setScale(SCALE, HALF_UP)

What a reviewer would raise. The service knows only two of the legacy app's three products: PAPER-A4 and CABLE-USB, the two the written contract's cases use. A preview with the black toner cartridge, TONER-BK, would come back as not found. A reviewer would add it, or read the products from the database.

Bob at work

From a recorded Bob session, trimmed to four moments. Bob worked from a written brief that listed what the code must do; the moments show how he carried it out. Our commentary is on the left.

  1. 1

    Bob reads the contract the Kotlin service must meet. Before changing anything he reads the written contract: the route, the pricing rule and every case with its expected totals.

    Now let me read all the relevant inputs and existing files before making any changes.

    read file inputs/invoice-preview-v1.json

    5 |   "endpoint": "/api/invoice-preview",
    9 |   "preview_rule": "qty >= 100 receives the inclusive 2 percent bulk discount after the customer-tier discount",
    70 |       "id": "caller-tier-ignored",
    71 |       "request": {"customerId": 1, "tier": "C", "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    72 |       "expected_status": 200,
    73 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "9.50", "gst": "0.86", "total": "10.36"}
    All 107 lines
    1 | {
    2 |   "schema_version": 1,
    3 |   "contract_id": "invoice-preview-v1",
    4 |   "business_capability": "invoice-preview",
    5 |   "endpoint": "/api/invoice-preview",
    6 |   "health_endpoint": "/internal/health",
    7 |   "method": "POST",
    8 |   "content_type": "application/json",
    9 |   "preview_rule": "qty >= 100 receives the inclusive 2 percent bulk discount after the customer-tier discount",
    10 |   "known_compatibility_boundary": "The legacy save path differs at exactly qty=100; this preview contract intentionally retains total 1014.79.",
    11 |   "response_fields": [
    12 |     "customerId",
    13 |     "customerName",
    14 |     "tier",
    15 |     "subtotal",
    16 |     "gst",
    17 |     "total",
    18 |     "lines"
    19 |   ],
    20 |   "line_response_fields": [
    21 |     "lineNo",
    22 |     "productCode",
    23 |     "qty",
    24 |     "unitPrice",
    25 |     "lineNet"
    26 |   ],
    27 |   "health_requirements": {
    28 |     "status": "UP",
    29 |     "readOnly": true,
    30 |     "write_count_field": "writeCount"
    31 |   },
    32 |   "cases": [
    33 |     {
    34 |       "id": "qty=99",
    35 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 99, "unitPrice": "10.00"}]},
    36 |       "expected_status": 200,
    37 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.50", "gst": "84.65", "total": "1025.15"}
    38 |     },
    39 |     {
    40 |       "id": "qty=100",
    41 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 100, "unitPrice": "10.00"}]},
    42 |       "expected_status": 200,
    43 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "931.00", "gst": "83.79", "total": "1014.79"}
    44 |     },
    45 |     {
    46 |       "id": "qty=101",
    47 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 101, "unitPrice": "10.00"}]},
    48 |       "expected_status": 200,
    49 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.31", "gst": "84.63", "total": "1024.94"}
    50 |     },
    51 |     {
    52 |       "id": "rounding",
    53 |       "request": {"customerId": 1, "lines": [{"productCode": "CABLE-USB", "qty": 5, "unitPrice": "3.3350"}]},
    54 |       "expected_status": 200,
    55 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "15.84", "gst": "1.43", "total": "17.27"}
    56 |     },
    57 |     {
    58 |       "id": "tier=B",
    59 |       "request": {"customerId": 2, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    60 |       "expected_status": 200,
    61 |       "expected": {"customerName": "Merlion Retail", "tier": "B", "subtotal": "9.70", "gst": "0.87", "total": "10.57"}
    62 |     },
    63 |     {
    64 |       "id": "tier=C",
    65 |       "request": {"customerId": 3, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    66 |       "expected_status": 200,
    67 |       "expected": {"customerName": "Straits Workshop", "tier": "C", "subtotal": "10.00", "gst": "0.90", "total": "10.90"}
    68 |     },
    69 |     {
    70 |       "id": "caller-tier-ignored",
    71 |       "request": {"customerId": 1, "tier": "C", "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    72 |       "expected_status": 200,
    73 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "9.50", "gst": "0.86", "total": "10.36"}
    74 |     },
    75 |     {
    76 |       "id": "missing-customer",
    77 |       "request": {"lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    78 |       "expected_status": 400
    79 |     },
    80 |     {
    81 |       "id": "missing-lines",
    82 |       "request": {"customerId": 1},
    83 |       "expected_status": 400
    84 |     },
    85 |     {
    86 |       "id": "malformed-qty-string",
    87 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": "100", "unitPrice": "10.00"}]},
    88 |       "expected_status": 400
    89 |     },
    90 |     {
    91 |       "id": "malformed-qty-zero",
    92 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 0, "unitPrice": "10.00"}]},
    93 |       "expected_status": 400
    94 |     },
    95 |     {
    96 |       "id": "unknown-customer",
    97 |       "request": {"customerId": 999, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    98 |       "expected_status": 404
    99 |     },
    100 |     {
    101 |       "id": "unknown-product",
    102 |       "request": {"customerId": 1, "lines": [{"productCode": "UNKNOWN", "qty": 1, "unitPrice": "10.00"}]},
    103 |       "expected_status": 404
    104 |     }
    105 |   ]
    106 | }
    107 | 
  2. 2

    Bob sets out his design. As the brief asks, the price tier now comes from the service's own customer records, never from the caller.

    Design decisions: Server-side customer map: 1→(Aster Office Supplies, A), 2→(Merlion Retail, B), 3→(Straits Workshop, C) Server-side product set: PAPER-A4, CABLE-USB (both used in contract cases) customerId missing/null → 400; unknown → 404 unknown productCode → 404 caller tier field silently ignored (not even validated) PreviewRequest drops tier as a field; it comes from customer lookup PreviewResponse adds customerName
  3. 3

    Bob tidies a test he has just written. With the new service and its tests written, he reads one test back, finds it awkward and rewrites it in a plainer form.

    I notice the caseTierB function has an awkward inline pattern with the nested also block. Let me review and fix it to be clean:

    apply diff targets/kotlin/src/test/kotlin/PreviewTests.kt

     private fun caseTierB() {
    +    val resp = calculatePreview(req)
    +    assertEquals("tierB customerName", "Merlion Retail", resp.customerName)
    +    assertEquals("tierB tier",         "B",              resp.tier)
    +    assertEquals("tierB gst",          "0.87",           resp.gst.toPlainString())
    +    assertEquals("tierB total",        "10.57",          resp.total.toPlainString())
    All 32 lines
    Edited file: targets/kotlin/src/test/kotlin/PreviewTests.kt
    
    
    
    Patch of edit:
    
    <patch>
    @@ -65,15 +65,14 @@
     
     private fun caseTierB() {
         // customerId 2 → Merlion Retail, tier B; caller supplies no tier
         val req = parseAndValidate("""{"customerId":2,"lines":[{"productCode":"PAPER-A4","qty":1,"unitPrice":"10.00"}]}""")
    -    assertEquals("tierB customerName", "Merlion Retail", resp = calculatePreview(req).also { resp ->
    -        assertEquals("tierB customerName", "Merlion Retail", resp.customerName)
    -        assertEquals("tierB tier",         "B",              resp.tier)
    -        assertEquals("tierB subtotal",     "9.70",           resp.subtotal.toPlainString())
    -        assertEquals("tierB gst",          "0.87",           resp.gst.toPlainString())
    -        assertEquals("tierB total",        "10.57",          resp.total.toPlainString())
    -    }.customerName)
    +    val resp = calculatePreview(req)
    +    assertEquals("tierB customerName", "Merlion Retail", resp.customerName)
    +    assertEquals("tierB tier",         "B",              resp.tier)
    +    assertEquals("tierB subtotal",     "9.70",           resp.subtotal.toPlainString())
    +    assertEquals("tierB gst",          "0.87",           resp.gst.toPlainString())
    +    assertEquals("tierB total",        "10.57",          resp.total.toPlainString())
     }
     
     private fun caseTierC() {
         // customerId 3 → Straits Workshop, tier C
    
    </patch>
    
  4. 4

    Bob traces the tricky cases through his code. He checks that a request with no customer is refused as a bad request before any lookup, and that an unknown customer is reported as not found.

    The test file looks correct. Let me also verify the server file's key sections one more time to make sure parseAndValidate ordering is correct — specifically that validation (400) fires before the customer lookup (404) for the malformed cases:
    The ordering is correct. Now let me trace the contract case missing-customer (no customerId) to confirm it stays 400: customerId missing → throw ValidationException("Missing customerId") → HTTP 400 ✓ And unknown-customer (customerId: 999): Parse succeeds, customerId = 999L CUSTOMERS[999L] → null → throw NotFoundException(...) → HTTP 404 ✓

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: build and test it yourself

From the repository root

kotlinc targets/kotlin/src/main/kotlin/PreviewServer.kt targets/kotlin/src/test/kotlin/PreviewTests.kt -include-runtime -d targets/kotlin/build/preview-tests.jar
java -cp targets/kotlin/build/preview-tests.jar PreviewTestsKt

The code and its build notes are in targets/kotlin/.

Back to the seven migrations

Migration walkthrough · one feature, not a full migration

TypeScript

What moved: browser side of invoice preview moved to TypeScript.

What the assessment said, before anything was built

low-risk quality improvement, recommended as a parallel track

A judgement from reading the code: no trial build was run at assessment.

How it was checked

Bob wrote the code and its tests, but had no way to run them in his session. We ran them when this repository was put together: 42 passed, 0 failed.

These are Bob's own tests. They show the feature gives the legacy answers on the cases Bob wrote; they do not prove every case.

Before and after

Before Try the legacy app

  • Browser the Invoice Desk screen
  • Java 8 app works out the preview in Desk.calculate()
  • H2 database customers, their price tiers and products

In the code that moved, Bob's assessment found:

  • F-007 low 50-line invoice limit enforced server-side only; browser UI provides no feedback Still no feedback on the 50-line limit; the panel sends one line, so it never reaches it.

After Built and tested on its own

  • Browser unchanged
  • New, under the Browser: TypeScript preview panel browser code; the server still does the pricing not served by anything in this demo
  • Java 8 app unchanged
  • H2 database unchanged

To connect it: The old app would have to serve the compiled panel: it calls the preview address on the server that served it, and the old app has that address today.

Our drawing, from the code in targets/typescript/.

Same order, both answers

The three orders you can try on the legacy app, passed by this target's client to the legacy app on our machine on 23 October 2026. Every figure is what the program answered.

OrderWhat its typed client sentThe answer it checked and passed onVerdict
Aster Office Supplies100 × PAPER-A4POST /api/invoices/preview
100 × PAPER-A4 at 10.00
931.00 + GST 83.79 = 1014.79The legacy app's own answer
Merlion Retail10 × TONER-BKPOST /api/invoices/preview
10 × TONER-BK at 50.00
485.00 + GST 43.65 = 528.65The legacy app's own answer
Straits Workshop3 × CABLE-USB, 101 × PAPER-A4POST /api/invoices/preview
3 × CABLE-USB at 3.3350, 101 × PAPER-A4 at 10.00
999.81 + GST 89.98 = 1089.79The legacy app's own answer

The panel calculates nothing. Called with each order, its typed client sent it to the legacy app's own preview address and checked that every field of the answer was there and in the right form before handing it back.

The browser side: the untyped preview handler becomes a typed function.

Before the legacy app · app/src/main/resources/static/desk.js
$('preview-invoice').onclick=()=>act(async()=>{
  const result=await post('/invoices/preview',draft());
  $('preview-body').textContent=result.customerName+' · '+result.lines.length+' line(s) · subtotal '+money(result.subtotal)+' · GST '+money(result.gst);
  $('preview-total').textContent=money(result.total);
},'Preview calculated.');
After written by Bob · targets/typescript/src/browser.ts
/** Renders a successful preview response into the panel. */
export function renderPreview(
  response: PreviewResponse,
  totalEl: HTMLElement,
  errorEl: HTMLElement,
  customerNameEl: HTMLElement
): void {
  errorEl.textContent = "";
  errorEl.hidden = true;
  // [data-preview-customer-name] displays the resolved customer name from the response.
  customerNameEl.textContent = response.customerName;
  // [data-preview-total] must contain exactly the bare total (e.g. "1014.79").
  // Currency label is rendered outside this element in the HTML.
  totalEl.textContent = response.total;
}

What a reviewer would raise. Bob's panel shows the customer and the total, and sends one line per preview. The legacy panel previews several lines and also shows the line count, subtotal and GST. A reviewer would ask whether that narrowing is wanted.

Bob at work

From a recorded Bob session, trimmed to four moments. Bob worked from a written brief that listed what the code must do; the moments show how he carried it out. Our commentary is on the left.

  1. 1

    Bob reads the contract the browser page must follow. This target moves the browser side of invoice preview to TypeScript, so he starts with the route and the exact fields the page will receive.

    read file inputs/invoice-preview-v1.json

    5 |   "endpoint": "/api/invoice-preview",
    7 |   "method": "POST",
    11 |   "response_fields": [
    12 |     "customerId",
    13 |     "customerName",
    14 |     "tier",
    All 107 lines
    1 | {
    2 |   "schema_version": 1,
    3 |   "contract_id": "invoice-preview-v1",
    4 |   "business_capability": "invoice-preview",
    5 |   "endpoint": "/api/invoice-preview",
    6 |   "health_endpoint": "/internal/health",
    7 |   "method": "POST",
    8 |   "content_type": "application/json",
    9 |   "preview_rule": "qty >= 100 receives the inclusive 2 percent bulk discount after the customer-tier discount",
    10 |   "known_compatibility_boundary": "The legacy save path differs at exactly qty=100; this preview contract intentionally retains total 1014.79.",
    11 |   "response_fields": [
    12 |     "customerId",
    13 |     "customerName",
    14 |     "tier",
    15 |     "subtotal",
    16 |     "gst",
    17 |     "total",
    18 |     "lines"
    19 |   ],
    20 |   "line_response_fields": [
    21 |     "lineNo",
    22 |     "productCode",
    23 |     "qty",
    24 |     "unitPrice",
    25 |     "lineNet"
    26 |   ],
    27 |   "health_requirements": {
    28 |     "status": "UP",
    29 |     "readOnly": true,
    30 |     "write_count_field": "writeCount"
    31 |   },
    32 |   "cases": [
    33 |     {
    34 |       "id": "qty=99",
    35 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 99, "unitPrice": "10.00"}]},
    36 |       "expected_status": 200,
    37 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.50", "gst": "84.65", "total": "1025.15"}
    38 |     },
    39 |     {
    40 |       "id": "qty=100",
    41 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 100, "unitPrice": "10.00"}]},
    42 |       "expected_status": 200,
    43 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "931.00", "gst": "83.79", "total": "1014.79"}
    44 |     },
    45 |     {
    46 |       "id": "qty=101",
    47 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 101, "unitPrice": "10.00"}]},
    48 |       "expected_status": 200,
    49 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.31", "gst": "84.63", "total": "1024.94"}
    50 |     },
    51 |     {
    52 |       "id": "rounding",
    53 |       "request": {"customerId": 1, "lines": [{"productCode": "CABLE-USB", "qty": 5, "unitPrice": "3.3350"}]},
    54 |       "expected_status": 200,
    55 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "15.84", "gst": "1.43", "total": "17.27"}
    56 |     },
    57 |     {
    58 |       "id": "tier=B",
    59 |       "request": {"customerId": 2, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    60 |       "expected_status": 200,
    61 |       "expected": {"customerName": "Merlion Retail", "tier": "B", "subtotal": "9.70", "gst": "0.87", "total": "10.57"}
    62 |     },
    63 |     {
    64 |       "id": "tier=C",
    65 |       "request": {"customerId": 3, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    66 |       "expected_status": 200,
    67 |       "expected": {"customerName": "Straits Workshop", "tier": "C", "subtotal": "10.00", "gst": "0.90", "total": "10.90"}
    68 |     },
    69 |     {
    70 |       "id": "caller-tier-ignored",
    71 |       "request": {"customerId": 1, "tier": "C", "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    72 |       "expected_status": 200,
    73 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "9.50", "gst": "0.86", "total": "10.36"}
    74 |     },
    75 |     {
    76 |       "id": "missing-customer",
    77 |       "request": {"lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    78 |       "expected_status": 400
    79 |     },
    80 |     {
    81 |       "id": "missing-lines",
    82 |       "request": {"customerId": 1},
    83 |       "expected_status": 400
    84 |     },
    85 |     {
    86 |       "id": "malformed-qty-string",
    87 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": "100", "unitPrice": "10.00"}]},
    88 |       "expected_status": 400
    89 |     },
    90 |     {
    91 |       "id": "malformed-qty-zero",
    92 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 0, "unitPrice": "10.00"}]},
    93 |       "expected_status": 400
    94 |     },
    95 |     {
    96 |       "id": "unknown-customer",
    97 |       "request": {"customerId": 999, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    98 |       "expected_status": 404
    99 |     },
    100 |     {
    101 |       "id": "unknown-product",
    102 |       "request": {"customerId": 1, "lines": [{"productCode": "UNKNOWN", "qty": 1, "unitPrice": "10.00"}]},
    103 |       "expected_status": 404
    104 |     }
    105 |   ]
    106 | }
    107 | 
  2. 2

    Bob plans every change before he edits. He lists what each file needs: the page stops sending a price tier and starts showing the customer name the service returns.

    Plan: 1. preview.ts — remove tier from PreviewRequest; add customerName to PreviewResponse; update RESPONSE_TOP_FIELDS 2. browser.ts — remove tier reading/validation/request field; add customerName rendering 3. preview.test.ts — add customerName to fixture; remove tier from baseRequest; fix the body.tier assertion → assert tier is absent; add no-persistence test 4. index.html — remove tier input; add customer-name display element
  3. 3

    Bob writes the typed code. He changes the typed request and response models first, then rewires the browser code so it reads only the inputs the contract allows.

    apply diff targets/typescript/src/preview.ts

     /** Request body for POST /api/invoices/preview. */
     export interface PreviewRequest {
    -  tier: "A" | "B" | "C";
     export interface PreviewResponse {
       customerId: number;
    +  customerName: string;
    All 53 lines
    Edited file: targets/typescript/src/preview.ts
    
    
    
    Patch of edit:
    
    <patch>
    @@ -26,9 +26,8 @@
     
     /** Request body for POST /api/invoices/preview. */
     export interface PreviewRequest {
       customerId: number;
    -  tier: "A" | "B" | "C";
       lines: PreviewLine[];
     }
     
     /** A single line on an invoice preview response. */
    @@ -44,8 +43,9 @@
     
     /** Successful response from POST /api/invoices/preview. */
     export interface PreviewResponse {
       customerId: number;
    +  customerName: string;
       tier: string;
       /** Two-decimal money string. */
       subtotal: string;
       /** Two-decimal money string. */
    @@ -74,10 +74,10 @@
     
     /** Valid tier values. */
     const VALID_TIERS = new Set(["A", "B", "C"]);
     
    -/** Top-level response fields (exactly these). */
    -const RESPONSE_TOP_FIELDS = new Set(["customerId", "tier", "subtotal", "gst", "total", "lines"]);
    +/** Top-level response fields (exactly these, per invoice-preview-v1). */
    +const RESPONSE_TOP_FIELDS = new Set(["customerId", "customerName", "tier", "subtotal", "gst", "total", "lines"]);
     
     /** Line response fields (exactly these). */
     const RESPONSE_LINE_FIELDS = new Set(["lineNo", "productCode", "qty", "unitPrice", "lineNet"]);
     
    @@ -132,8 +132,10 @@
       }
     
       if (typeof o["customerId"] !== "number" || !Number.isInteger(o["customerId"]) || !Number.isFinite(o["customerId"]))
         throw new Error("PreviewResponse.customerId must be a finite integer number");
    +  if (typeof o["customerName"] !== "string" || (o["customerName"] as string).length === 0)
    +    throw new Error("PreviewResponse.customerName must be a non-empty string");
       if (typeof o["tier"] !== "string" || !VALID_TIERS.has(o["tier"] as string))
         throw new Error("PreviewResponse.tier must be A, B, or C");
       if (!isMoneyString(o["subtotal"]))
         throw new Error("PreviewResponse.subtotal must be a two-decimal money string");
    
    </patch>
    Now update browser.ts — remove tier, add customerName rendering:

    write file targets/typescript/src/browser.ts

    +  errorEl: HTMLElement,
    +  customerNameEl: HTMLElement
    +  // [data-preview-customer-name] displays the resolved customer name from the response.
    +  customerNameEl.textContent = response.customerName;
    -  if (tierRaw !== "A" && tierRaw !== "B" && tierRaw !== "C") {
    -    throw new Error("Tier must be A, B, or C");
    All 111 lines
    Edited file: targets/typescript/src/browser.ts
    
    
    
    Patch of edit:
    
    <patch>
    @@ -1,8 +1,8 @@
     /**
      * browser.ts — wires the invoice-preview panel to the DOM.
      *
    - * Reads form inputs named: customerId, tier, productCode, qty, unitPrice.
    + * Reads form inputs named: customerId, productCode, qty, unitPrice.
      * Uses stable data-preview-* selectors declared in index.html.
      * Never reads window.lines or any inaccessible global.
      *
      * Scope: POST /api/invoices/preview only. Never persists.
    @@ -27,12 +27,15 @@
     /** Renders a successful preview response into the panel. */
     export function renderPreview(
       response: PreviewResponse,
       totalEl: HTMLElement,
    -  errorEl: HTMLElement
    +  errorEl: HTMLElement,
    +  customerNameEl: HTMLElement
     ): void {
       errorEl.textContent = "";
       errorEl.hidden = true;
    +  // [data-preview-customer-name] displays the resolved customer name from the response.
    +  customerNameEl.textContent = response.customerName;
       // [data-preview-total] must contain exactly the bare total (e.g. "1014.79").
       // Currency label is rendered outside this element in the HTML.
       totalEl.textContent = response.total;
     }
    @@ -50,9 +53,9 @@
       totalEl.textContent = "0.00";
     }
     
     // ---------------------------------------------------------------------------
    -// Request builder — reads only the five declared form inputs
    +// Request builder — reads only the four declared form inputs
     // ---------------------------------------------------------------------------
     
     function buildRequest(form: HTMLElement): PreviewRequest {
       function inputVal(name: string): string {
    @@ -60,9 +63,8 @@
         return el ? el.value.trim() : "";
       }
     
       const customerIdRaw = inputVal("customerId");
    -  const tierRaw = inputVal("tier");
       const productCode = inputVal("productCode");
       const qtyRaw = inputVal("qty");
       const unitPriceRaw = inputVal("unitPrice");
     
    @@ -70,13 +72,8 @@
       if (!Number.isInteger(customerId) || customerId < 1) {
         throw new Error("Enter a valid customer ID (integer ≥ 1)");
       }
     
    -  if (tierRaw !== "A" && tierRaw !== "B" && tierRaw !== "C") {
    -    throw new Error("Tier must be A, B, or C");
    -  }
    -  const tier = tierRaw as "A" | "B" | "C";
    -
       if (!productCode) {
         throw new Error("Enter a product code");
       }
     
    @@ -92,9 +89,8 @@
       }
     
       return {
         customerId,
    -    tier,
         lines: [{ productCode, qty, unitPrice: unitPriceRaw }],
       };
     }
     
    @@ -109,16 +105,18 @@
     export function mountPreviewPanel(): void {
       const formEl = requireEl<HTMLFormElement>("[data-preview-form]");
       const totalEl = requireEl<HTMLElement>("[data-preview-total]");
       const errorEl = requireEl<HTMLElement>("[data-preview-error]");
    +  const customerNameEl = requireEl<HTMLElement>("[data-preview-customer-name]");
     
       const handleSubmit = async (e: Event): Promise<void> => {
         e.preventDefault();
     
         // Clear previous state
         errorEl.textContent = "";
         errorEl.hidden = true;
         totalEl.textContent = "Calculating…";
    +    customerNameEl.textContent = "";
     
         let request: PreviewRequest;
         try {
           request = buildRequest(formEl);
    @@ -128,9 +126,9 @@
         }
     
         try {
           const response = await fetchPreview(request);
    -      renderPreview(response, totalEl, errorEl);
    +      renderPreview(response, totalEl, errorEl, customerNameEl);
         } catch (err) {
           renderError(err, totalEl, errorEl);
         }
       };
    
    </patch>
  4. 4

    Bob reads his changes back. Before finishing he reads each changed file again to confirm the request no longer carries a tier and the response carries the customer name.

    Now do a final verification pass — read each changed file to confirm correctness:

    read file targets/typescript/src/preview.ts

    28 | export interface PreviewRequest {
    29 |   customerId: number;
    30 |   lines: PreviewLine[];
    31 | }
    46 |   customerId: number;
    47 |   customerName: string;
    All 33 lines
    28 | export interface PreviewRequest {
    29 |   customerId: number;
    30 |   lines: PreviewLine[];
    31 | }
    32 | 
    33 | /** A single line on an invoice preview response. */
    34 | export interface PreviewResponseLine {
    35 |   lineNo: number;
    36 |   productCode: string;
    37 |   qty: number;
    38 |   /** Four-decimal money string from the controller, e.g. "10.0000". */
    39 |   unitPrice: string;
    40 |   /** Two-decimal money string. */
    41 |   lineNet: string;
    42 | }
    43 | 
    44 | /** Successful response from POST /api/invoices/preview. */
    45 | export interface PreviewResponse {
    46 |   customerId: number;
    47 |   customerName: string;
    48 |   tier: string;
    49 |   /** Two-decimal money string. */
    50 |   subtotal: string;
    51 |   /** Two-decimal money string. */
    52 |   gst: string;
    53 |   /** Two-decimal money string. */
    54 |   total: string;
    55 |   lines: PreviewResponseLine[];
    56 | }
    57 | 
    58 | // ---------------------------------------------------------------------------
    59 | // Route constant — the ONLY route this module ever calls
    60 | // ---------------------------------------------------------------------------

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: build and test it yourself

From the repository root

cd targets/typescript
npm install
npm test

The code and its build notes are in targets/typescript/.

Back to the seven migrations

Migration walkthrough · one feature, not a full migration

H2 to PostgreSQL

What moved: customer and product reference data for invoice preview moved from H2 to PostgreSQL.

What the assessment said, before anything was built

required for any production deployment; highest-impact structural change; after the Java 21 / Spring Boot 3 upgrade

A judgement from reading the code: no trial build was run at assessment.

How it was checked

Loading the tables needs a PostgreSQL server, which the machine that prepared this repository did not have, so that step is not run here.

Bob also wrote a small reference file with the rows and the preview arithmetic it expects, so a real PostgreSQL database can be compared against it.

Before and after

Before Try the legacy app

  • Browser the Invoice Desk screen
  • Java 8 app works out the preview in Desk.calculate()
  • H2 database customers, their price tiers and products

In the code that moved, Bob's assessment found:

  • F-014 medium Schema management via inline DDL at @PostConstruct; no migration framework Not yet addressed: the script is a first separate file for two of the tables, but nothing runs it, and the app still creates all eight tables itself when it starts.

After Script only, not connected to the app

  • Browser unchanged
  • Java 8 app unchanged
  • H2 database unchanged
  • New, under the H2 database: PostgreSQL tables customers and products, as one script not connected to the app

To connect it: Run the script on a PostgreSQL database, add a PostgreSQL driver to the old app and point it there. The other six tables and their demo data, and the reports' H2-only date function (F-016), would still have to move. We have not tried this.

Our drawing, from the code in targets/postgresql/.

Its own check, as it ran

Run on our machine on 23 October 2026.

python targets/postgresql/verify_parity.py

⋯ 1 line not shownPASS  qty=99                  total expected=1025.15 actual=1025.15  tier expected=A actual=A  name expected='Aster Office Supplies' actual='Aster Office Supplies'PASS  qty=100                 total expected=1014.79 actual=1014.79  tier expected=A actual=A  name expected='Aster Office Supplies' actual='Aster Office Supplies'PASS  qty=101                 total expected=1024.94 actual=1024.94  tier expected=A actual=A  name expected='Aster Office Supplies' actual='Aster Office Supplies'PASS  rounding                total expected=17.27 actual=17.27  tier expected=A actual=A  name expected='Aster Office Supplies' actual='Aster Office Supplies'PASS  tier=B                  total expected=10.57 actual=10.57  tier expected=B actual=B  name expected='Merlion Retail' actual='Merlion Retail'PASS  tier=C                  total expected=10.90 actual=10.90  tier expected=C actual=C  name expected='Straits Workshop' actual='Straits Workshop'FAIL  tier-B-bulk             total expected=5180.77 actual=5874.99  tier expected=B actual=B  name expected='Merlion Retail' actual='Merlion Retail'PASS  caller-tier-ignored     total expected=10.36 actual=10.36  tier expected=A actual=A  name expected='Aster Office Supplies' actual='Aster Office Supplies'PASS  unknown-customer        raised_ValueError=True  fragment_matched=True  expected_fragment='unknown-customer:999'PASS  unknown-product         raised_ValueError=True  fragment_matched=True  expected_fragment='unknown-product:UNKNOWN'⋯ 3 lines not shown

Exit code 1

No preview runs on these tables here: loading them needs a PostgreSQL server, which we did not have. What did run is the reference file's own self-check, with Python 3.13.0: 9 of its 10 cases pass. For tier-B-bulk, the total Bob wrote down as expected is not what the file's own arithmetic gives, so the script ends with an error. That case is an open question for review, not a database result.

The two reference tables the preview reads, as H2 creates them today and as Bob wrote them for PostgreSQL.

Before the legacy app · app/src/main/java/com/ingramtrials/base/Desk.java
"CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY, name VARCHAR(120), tier CHAR(1), email VARCHAR(160))",
"CREATE TABLE IF NOT EXISTS products (code VARCHAR(40) PRIMARY KEY, description VARCHAR(160), unit_price DECIMAL(18,4), category VARCHAR(80))",
After written by Bob · targets/postgresql/migrations/001_preview_reference_data.sql
CREATE TABLE customers (id INTEGER NOT NULL, name VARCHAR(120) NOT NULL, tier CHAR(1) NOT NULL, email VARCHAR(200) NOT NULL);
CREATE TABLE products (code VARCHAR(40) NOT NULL, description VARCHAR(200) NOT NULL, unit_price NUMERIC(14,4) NOT NULL, category VARCHAR(80) NOT NULL);

What a reviewer would raise. Bob's PostgreSQL tables leave out the primary keys the H2 tables have (customer id, product code). A reviewer would add them back before any real data moves.

Bob at work

From a recorded Bob session, trimmed to four moments. Bob worked from a written brief that listed what the code must do; the moments show how he carried it out. Our commentary is on the left.

  1. 1

    Bob reads the reference data in its PostgreSQL form. Invoice preview depends on a small set of customers and products. He reads the PostgreSQL schema and starting rows and judges them right as they stand.

    read file targets/postgresql/migrations/001_preview_reference_data.sql

    1 | CREATE TABLE customers (id INTEGER NOT NULL, name VARCHAR(120) NOT NULL, tier CHAR(1) NOT NULL, email VARCHAR(200) NOT NULL);
    2 | CREATE TABLE products (code VARCHAR(40) NOT NULL, description VARCHAR(200) NOT NULL, unit_price NUMERIC(14,4) NOT NULL, category VARCHAR(80) NOT NULL);
    5 | INSERT INTO customers (id, name, tier, email) VALUES (1, 'Aster Office Supplies', 'A', 'orders@aster.example');
    6 | INSERT INTO customers (id, name, tier, email) VALUES (2, 'Merlion Retail', 'B', 'finance@merlion.example');
    7 | INSERT INTO customers (id, name, tier, email) VALUES (3, 'Straits Workshop', 'C', 'accounts@straits.example');
    9 | INSERT INTO products (code, description, unit_price, category) VALUES ('PAPER-A4', 'A4 paper carton', 10.0000, 'Office');
    All 11 lines
    1 | CREATE TABLE customers (id INTEGER NOT NULL, name VARCHAR(120) NOT NULL, tier CHAR(1) NOT NULL, email VARCHAR(200) NOT NULL);
    2 | CREATE TABLE products (code VARCHAR(40) NOT NULL, description VARCHAR(200) NOT NULL, unit_price NUMERIC(14,4) NOT NULL, category VARCHAR(80) NOT NULL);
    3 | CREATE INDEX customers_tier_idx ON customers (tier);
    4 | CREATE INDEX products_category_idx ON products (category);
    5 | INSERT INTO customers (id, name, tier, email) VALUES (1, 'Aster Office Supplies', 'A', 'orders@aster.example');
    6 | INSERT INTO customers (id, name, tier, email) VALUES (2, 'Merlion Retail', 'B', 'finance@merlion.example');
    7 | INSERT INTO customers (id, name, tier, email) VALUES (3, 'Straits Workshop', 'C', 'accounts@straits.example');
    8 | INSERT INTO products (code, description, unit_price, category) VALUES ('CABLE-USB', 'USB cable pack', 3.3350, 'Accessories');
    9 | INSERT INTO products (code, description, unit_price, category) VALUES ('PAPER-A4', 'A4 paper carton', 10.0000, 'Office');
    10 | INSERT INTO products (code, description, unit_price, category) VALUES ('TONER-BK', 'Black toner cartridge', 50.0000, 'Print');
    11 | 
    SQL file — the schema and seed data are correct; no changes needed there.
  2. 2

    Bob confirms the gap the brief names. The parity file is what compares a real PostgreSQL database with the old one. He confirms it takes the price tier on trust instead of looking the customer up.

    2. No proof that injected tier C for customerId 1 is ignored (no DB lookup logic)
  3. 3

    Bob reads back the rewritten check. After rewriting the file he reads the key part again: the customer and every product are now looked up before any price is worked out.

    Both files are written. Let me do a final read-through of the key sections to confirm correctness:

    read file targets/postgresql/verify_parity.py

    94 | def compute_invoice(customer_id, lines):
    96 |     Resolve customerName and tier by querying EXPECTED_CUSTOMERS for customer_id.
    97 |     Confirm each productCode exists in EXPECTED_PRODUCTS before pricing.
    106 |     customer = _lookup_customer(customer_id)
    107 |     if customer is None:
    108 |         raise ValueError(f"unknown-customer:{customer_id}")
    All 43 lines
    88 |     after_tier = price * (Decimal("1") - tier_d)
    89 |     if qty >= BULK_THRESHOLD:
    90 |         after_tier = after_tier * (Decimal("1") - BULK_DISCOUNT)
    91 |     return _round(after_tier * quantity)
    92 | 
    93 | 
    94 | def compute_invoice(customer_id, lines):
    95 |     """
    96 |     Resolve customerName and tier by querying EXPECTED_CUSTOMERS for customer_id.
    97 |     Confirm each productCode exists in EXPECTED_PRODUCTS before pricing.
    98 | 
    99 |     lines: list of {"productCode": str, "qty": int, "unitPrice": str/Decimal}
    100 | 
    101 |     Returns dict with customerId, customerName, tier, subtotal, gst, total,
    102 |     or raises ValueError for unknown customer / unknown product.
    103 | 
    104 |     Caller-supplied tier is intentionally not accepted as a parameter.
    105 |     """
    106 |     customer = _lookup_customer(customer_id)
    107 |     if customer is None:
    108 |         raise ValueError(f"unknown-customer:{customer_id}")
    109 | 
    110 |     tier = customer["tier"]
    111 |     customer_name = customer["name"]
    112 | 
    113 |     line_nets = []
    114 |     for ln in lines:
    115 |         product = _lookup_product(ln["productCode"])
    116 |         if product is None:
    117 |             raise ValueError(f"unknown-product:{ln['productCode']}")
    118 |         line_nets.append(compute_line_net(ln["unitPrice"], ln["qty"], tier))
    119 | 
    120 |     subtotal = sum(line_nets)
    121 |     gst = _round(subtotal * GST_RATE)
    122 |     total = _round(subtotal + gst)
    123 |     return {
    124 |         "customerId": customer_id,
    125 |         "customerName": customer_name,
    126 |         "tier": tier,
    127 |         "subtotal": _round(subtotal),
    128 |         "gst": gst,
    129 |         "total": total,
    130 |     }
  4. 4

    Bob keeps the sums untouched. His summary notes that the pricing arithmetic did not change, so the expected boundary total stays exactly as the contract states it.

    qty=100 total preserved1014.79 retained in "qty=100" case — no arithmetic was changed

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: build and test it yourself

From the repository root

psql -h 127.0.0.1 -U <user> -d <scratch database> -v ON_ERROR_STOP=1 -f targets/postgresql/migrations/001_preview_reference_data.sql
python targets/postgresql/verify_parity.py

The code and its build notes are in targets/postgresql/.

Back to the seven migrations

Migration walkthrough · one feature, not a full migration

Microservice

What moved: invoice preview pulled out as its own Spring Boot service on Java 21.

What the assessment said, before anything was built

viable future option, not a recommended immediate action

A judgement from reading the code: no trial build was run at assessment.

How it was checked

Bob wrote the code and its tests, but had no way to run them in his session. We ran them when this repository was put together: 43 tests, BUILD SUCCESS.

These are Bob's own tests. They show the feature gives the legacy answers on the cases Bob wrote; they do not prove every case.

Before and after

Before Try the legacy app

  • Browser the Invoice Desk screen
  • Java 8 app works out the preview in Desk.calculate()
  • H2 database customers, their price tiers and products

In the code that moved, Bob's assessment found:

  • F-004 high Preview/save bulk-discount off-by-one: qty=100 discounted in preview but not in save Kept: the service still discounts at 100 items, and its tests check 99 and 100.
  • F-007 low 50-line invoice limit enforced server-side only; browser UI provides no feedback Not just unchecked in the browser now: the service has no line limit at all.

After Built and tested on its own

  • Browser unchanged
  • Java 8 app unchanged
  • New, under the Java 8 app: Spring Boot service Java 21, with its own copy of the customers and two products not connected in this demo
  • H2 database unchanged

To connect it: The old app or the browser would call /api/invoice-preview in place of its own preview, and the service would read customers and products from the database in place of its built-in copy.

Our drawing, from the code in targets/microservices/.

Same order, both answers

The three orders you can try on the legacy app, sent to it and to this target on our machine on 23 October 2026. Every figure is what the program answered.

OrderThe legacy app POST /api/invoices/previewThe Spring Boot service POST /api/invoice-previewVerdict
Aster Office Supplies100 × PAPER-A4931.00 + GST 83.79 = 1014.79931.00 + GST 83.79 = 1014.79Same answer
Merlion Retail10 × TONER-BK485.00 + GST 43.65 = 528.65Refused: HTTP 404, “Unknown productCode: TONER-BK”Different: refused
Straits Workshop3 × CABLE-USB, 101 × PAPER-A4999.81 + GST 89.98 = 1089.79999.81 + GST 89.98 = 1089.79Same answer

The same answer, field for field, for the two orders it can price. It refuses the third: it knows only two of the three products.

Invoice preview as its own service, with the same rule. The monolith keeps everything else.

Before the legacy app · app/src/main/java/com/ingramtrials/base/Desk.java
BigDecimal previewLineNet(String tier, int qty, BigDecimal price) {
    BigDecimal net=price.multiply(BigDecimal.valueOf(qty));
    if ("A".equals(tier)) net=net.multiply(new BigDecimal("0.95"));
    if ("B".equals(tier)) net=net.multiply(new BigDecimal("0.97"));
    if (qty>=100) net=net.multiply(new BigDecimal("0.98"));
    return net.setScale(2,RoundingMode.HALF_UP);
}
After written by Bob · targets/microservices/src/main/java/demo/preview/PreviewApplication.java
static BigDecimal lineNet(int qty, BigDecimal unitPrice, BigDecimal tierDisc) {
    BigDecimal gross = unitPrice.multiply(BigDecimal.valueOf(qty));
    BigDecimal afterTier = gross.multiply(BigDecimal.ONE.subtract(tierDisc));
    BigDecimal afterBulk = qty >= BULK_QTY
            ? afterTier.multiply(BigDecimal.ONE.subtract(BULK))
            : afterTier;
    return afterBulk.setScale(2, RoundingMode.HALF_UP);

What a reviewer would raise. The service knows only two of the legacy app's three products: PAPER-A4 and CABLE-USB, the two the written contract's cases use. A preview with the black toner cartridge, TONER-BK, would come back as not found. A reviewer would add it, or read the products from the database.

Bob at work

From a recorded Bob session, trimmed to four moments. Bob worked from a written brief that listed what the code must do; the moments show how he carried it out. Our commentary is on the left.

  1. 1

    Bob reads the contract for the new service. Invoice preview is being lifted out of the old application into its own service. He starts with the routes it must offer and the rule that a preview never writes anything.

    read file inputs/invoice-preview-v1.json

    5 |   "endpoint": "/api/invoice-preview",
    6 |   "health_endpoint": "/internal/health",
    7 |   "method": "POST",
    9 |   "preview_rule": "qty >= 100 receives the inclusive 2 percent bulk discount after the customer-tier discount",
    28 |     "status": "UP",
    29 |     "readOnly": true,
    All 107 lines
    1 | {
    2 |   "schema_version": 1,
    3 |   "contract_id": "invoice-preview-v1",
    4 |   "business_capability": "invoice-preview",
    5 |   "endpoint": "/api/invoice-preview",
    6 |   "health_endpoint": "/internal/health",
    7 |   "method": "POST",
    8 |   "content_type": "application/json",
    9 |   "preview_rule": "qty >= 100 receives the inclusive 2 percent bulk discount after the customer-tier discount",
    10 |   "known_compatibility_boundary": "The legacy save path differs at exactly qty=100; this preview contract intentionally retains total 1014.79.",
    11 |   "response_fields": [
    12 |     "customerId",
    13 |     "customerName",
    14 |     "tier",
    15 |     "subtotal",
    16 |     "gst",
    17 |     "total",
    18 |     "lines"
    19 |   ],
    20 |   "line_response_fields": [
    21 |     "lineNo",
    22 |     "productCode",
    23 |     "qty",
    24 |     "unitPrice",
    25 |     "lineNet"
    26 |   ],
    27 |   "health_requirements": {
    28 |     "status": "UP",
    29 |     "readOnly": true,
    30 |     "write_count_field": "writeCount"
    31 |   },
    32 |   "cases": [
    33 |     {
    34 |       "id": "qty=99",
    35 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 99, "unitPrice": "10.00"}]},
    36 |       "expected_status": 200,
    37 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.50", "gst": "84.65", "total": "1025.15"}
    38 |     },
    39 |     {
    40 |       "id": "qty=100",
    41 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 100, "unitPrice": "10.00"}]},
    42 |       "expected_status": 200,
    43 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "931.00", "gst": "83.79", "total": "1014.79"}
    44 |     },
    45 |     {
    46 |       "id": "qty=101",
    47 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 101, "unitPrice": "10.00"}]},
    48 |       "expected_status": 200,
    49 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.31", "gst": "84.63", "total": "1024.94"}
    50 |     },
    51 |     {
    52 |       "id": "rounding",
    53 |       "request": {"customerId": 1, "lines": [{"productCode": "CABLE-USB", "qty": 5, "unitPrice": "3.3350"}]},
    54 |       "expected_status": 200,
    55 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "15.84", "gst": "1.43", "total": "17.27"}
    56 |     },
    57 |     {
    58 |       "id": "tier=B",
    59 |       "request": {"customerId": 2, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    60 |       "expected_status": 200,
    61 |       "expected": {"customerName": "Merlion Retail", "tier": "B", "subtotal": "9.70", "gst": "0.87", "total": "10.57"}
    62 |     },
    63 |     {
    64 |       "id": "tier=C",
    65 |       "request": {"customerId": 3, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    66 |       "expected_status": 200,
    67 |       "expected": {"customerName": "Straits Workshop", "tier": "C", "subtotal": "10.00", "gst": "0.90", "total": "10.90"}
    68 |     },
    69 |     {
    70 |       "id": "caller-tier-ignored",
    71 |       "request": {"customerId": 1, "tier": "C", "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    72 |       "expected_status": 200,
    73 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "9.50", "gst": "0.86", "total": "10.36"}
    74 |     },
    75 |     {
    76 |       "id": "missing-customer",
    77 |       "request": {"lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    78 |       "expected_status": 400
    79 |     },
    80 |     {
    81 |       "id": "missing-lines",
    82 |       "request": {"customerId": 1},
    83 |       "expected_status": 400
    84 |     },
    85 |     {
    86 |       "id": "malformed-qty-string",
    87 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": "100", "unitPrice": "10.00"}]},
    88 |       "expected_status": 400
    89 |     },
    90 |     {
    91 |       "id": "malformed-qty-zero",
    92 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 0, "unitPrice": "10.00"}]},
    93 |       "expected_status": 400
    94 |     },
    95 |     {
    96 |       "id": "unknown-customer",
    97 |       "request": {"customerId": 999, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    98 |       "expected_status": 404
    99 |     },
    100 |     {
    101 |       "id": "unknown-product",
    102 |       "request": {"customerId": 1, "lines": [{"productCode": "UNKNOWN", "qty": 1, "unitPrice": "10.00"}]},
    103 |       "expected_status": 404
    104 |     }
    105 |   ]
    106 | }
    107 | 
  2. 2

    Bob checks the code against the brief. Before writing, he confirms what is missing: there is no customer lookup and no check for unknown products.

    3. No customer table / lookup, no 404 for unknown customer 4. No product code validation / no 404 for unknown product (PAPER-A4, CABLE-USB are the known ones) 5. Response constructor missing customerName
  3. 3

    Bob writes the customer and product lookups. The service now finds the customer itself, takes the price tier from its own table and answers not found for anyone it does not know.

    write file targets/microservices/src/main/java/demo/preview/PreviewApplication.java

    +class CustomerRegistry {
    +    private static final List<Customer> CUSTOMERS = List.of(
    +            new Customer(1, "Aster Office Supplies", "A"),
    +        CustomerRegistry.Customer customer = CustomerRegistry.find(req.getCustomerId());
    +        if (customer == null) {
    +            return notFound("Unknown customerId: " + req.getCustomerId());
    All 189 lines
    Edited file: targets/microservices/src/main/java/demo/preview/PreviewApplication.java
    
    
    
    Patch of edit:
    
    <patch>
    @@ -29,8 +29,48 @@
         }
     }
     
     // ---------------------------------------------------------------------------
    +// Customer registry — server-side lookup; caller-supplied tier is ignored
    +// ---------------------------------------------------------------------------
    +
    +class CustomerRegistry {
    +
    +    record Customer(int id, String name, String tier) {}
    +
    +    private static final List<Customer> CUSTOMERS = List.of(
    +            new Customer(1, "Aster Office Supplies", "A"),
    +            new Customer(2, "Merlion Retail",        "B"),
    +            new Customer(3, "Straits Workshop",      "C")
    +    );
    +
    +    /** Returns null when customerId is not known. */
    +    static Customer find(int customerId) {
    +        for (Customer c : CUSTOMERS) {
    +            if (c.id() == customerId) return c;
    +        }
    +        return null;
    +    }
    +}
    +
    +// ---------------------------------------------------------------------------
    +// Product registry — known product codes; unknown codes produce 404
    +// ---------------------------------------------------------------------------
    +
    +class ProductRegistry {
    +
    +    private static final java.util.Set<String> KNOWN = java.util.Set.of(
    +            "PAPER-A4",
    +            "CABLE-USB"
    +    );
    +
    +    /** Returns true when the product code is registered. */
    +    static boolean isKnown(String productCode) {
    +        return productCode != null && KNOWN.contains(productCode);
    +    }
    +}
    +
    +// ---------------------------------------------------------------------------
     // Request / response types
     // ---------------------------------------------------------------------------
     
     class LineRequest {
    @@ -49,16 +89,16 @@
     }
     
     class PreviewRequest {
         private Integer customerId;
    -    private String tier;
         private List<LineRequest> lines;
     
         public Integer getCustomerId() { return customerId; }
         public void setCustomerId(Integer customerId) { this.customerId = customerId; }
     
    -    public String getTier() { return tier; }
    -    public void setTier(String tier) { this.tier = tier; }
    +    // tier field accepted in JSON but intentionally ignored; server resolves tier from customer table
    +    @SuppressWarnings("unused")
    +    public void setTier(String tier) { /* ignored */ }
     
         public List<LineRequest> getLines() { return lines; }
         public void setLines(List<LineRequest> lines) { this.lines = lines; }
     }
    @@ -86,29 +126,33 @@
     }
     
     class PreviewResponse {
         private final int customerId;
    +    private final String customerName;
         private final String tier;
         private final String subtotal;
         private final String gst;
         private final String total;
         private final List<LineResponse> lines;
     
    -    public PreviewResponse(int customerId, String tier, String subtotal,
    -                           String gst, String total, List<LineResponse> lines) {
    -        this.customerId = customerId;
    -        this.tier = tier;
    -        this.subtotal = subtotal;
    -        this.gst = gst;
    -        this.total = total;
    -        this.lines = lines;
    +    public PreviewResponse(int customerId, String customerName, String tier,
    +                           String subtotal, String gst, String total,
    +                           List<LineResponse> lines) {
    +        this.customerId   = customerId;
    +        this.customerName = customerName;
    +        this.tier         = tier;
    +        this.subtotal     = subtotal;
    +        this.gst          = gst;
    +        this.total        = total;
    +        this.lines        = lines;
         }
     
    -    public int getCustomerId() { return customerId; }
    -    public String getTier() { return tier; }
    -    public String getSubtotal() { return subtotal; }
    -    public String getGst() { return gst; }
    -    public String getTotal() { return total; }
    +    public int getCustomerId()      { return customerId; }
    +    public String getCustomerName() { return customerName; }
    +    public String getTier()         { return tier; }
    +    public String getSubtotal()     { return subtotal; }
    +    public String getGst()          { return gst; }
    +    public String getTotal()        { return total; }
         public List<LineResponse> getLines() { return lines; }
     }
     
     // ---------------------------------------------------------------------------
    @@ -191,18 +235,21 @@
             if (req == null) {
                 return badRequest("Request body is required");
             }
     
    -        // Validate customerId
    +        // Validate customerId present
             if (req.getCustomerId() == null) {
                 return badRequest("customerId is required");
             }
     
    -        // Validate tier
    -        String tier = req.getTier();
    -        if (tier == null || PricingEngine.tierDiscount(tier) == null) {
    -            return badRequest("tier must be A, B, or C");
    +        // Resolve customer server-side; return 404 for unknown customerId
    +        CustomerRegistry.Customer customer = CustomerRegistry.find(req.getCustomerId());
    +        if (customer == null) {
    +            return notFound("Unknown customerId: " + req.getCustomerId());
             }
    +
    +        // Tier resolved from customer table; caller-supplied tier is ignored
    +        String tier = customer.tier();
             BigDecimal tierDisc = PricingEngine.tierDiscount(tier);
     
             // Validate lines
             List<LineRequest> lineReqs = req.getLines();
    @@ -216,8 +263,13 @@
             for (int i = 0; i < lineReqs.size(); i++) {
                 LineRequest lr = lineReqs.get(i);
                 int lineNo = i + 1;
     
    +            // Product code validation — return 404 for unknown productCode
    +            if (!ProductRegistry.isKnown(lr.getProductCode())) {
    +                return notFound("Unknown productCode: " + lr.getProductCode());
    +            }
    +
                 // qty validation: must be a JSON integer (not string, not fractional)
                 Object rawQty = lr.getQty();
                 if (!(rawQty instanceof Integer)) {
                     return badRequest("line " + lineNo + ": qty must be a positive integer");
    @@ -259,9 +311,10 @@
             BigDecimal total = subtotal.add(gst);
     
             // Preview never increments writeCount
             return ResponseEntity.ok(new PreviewResponse(
    -                req.getCustomerId(),
    +                customer.id(),
    +                customer.name(),
                     tier,
                     subtotal.toPlainString(),
                     gst.toPlainString(),
                     total.toPlainString(),
    @@ -272,5 +325,10 @@
         private ResponseEntity<Map<String, String>> badRequest(String message) {
             return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                     .body(Map.of("error", message));
         }
    +
    +    private ResponseEntity<Map<String, String>> notFound(String message) {
    +        return ResponseEntity.status(HttpStatus.NOT_FOUND)
    +                .body(Map.of("error", message));
    +    }
     }
    
    </patch>
  4. 4

    Bob checks one case by hand. Before finishing he works through the case where a caller tries to choose a cheaper tier, and confirms the total matches the contract.

    Now let me verify the final state of all four files against the contract to confirm correctness before reporting:
    Let me verify the contract arithmetic for caller-tier-ignored: customerId=1, tier=A (server-resolved), qty=1, unitPrice=10.00. lineNet = 1 × 10.00 × (1-0.05) = 9.50. GST = 9.50 × 0.09 = 0.855 → 0.86 (HALF_UP). total = 9.50 + 0.86 = 10.36. Matches contract case exactly ✓

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: build and test it yourself

From the repository root

mvn -B -f targets/microservices/pom.xml verify

The code and its build notes are in targets/microservices/.

Back to the seven migrations

Migration walkthrough · one feature, not a full migration

Python (FastAPI)

What moved: invoice preview rebuilt as a FastAPI service.

What the assessment said, before anything was built

not recommended as the primary path; viable for a greenfield preview service; full rewrite

A judgement from reading the code: no trial build was run at assessment.

How it was checked

Bob wrote the code and its tests, but had no way to run them in his session. We ran them when this repository was put together: 36 tests OK.

These are Bob's own tests. They show the feature gives the legacy answers on the cases Bob wrote; they do not prove every case.

Before and after

Before Try the legacy app

  • Browser the Invoice Desk screen
  • Java 8 app works out the preview in Desk.calculate()
  • H2 database customers, their price tiers and products

In the code that moved, Bob's assessment found:

  • F-004 high Preview/save bulk-discount off-by-one: qty=100 discounted in preview but not in save Kept: the service still discounts at 100 items, and its tests check 99 and 100.
  • F-007 low 50-line invoice limit enforced server-side only; browser UI provides no feedback Not just unchecked in the browser now: the service has no line limit at all.

After Built and tested on its own

  • Browser unchanged
  • Java 8 app unchanged
  • New, under the Java 8 app: FastAPI service its own copy of the customers and two products not connected in this demo
  • H2 database unchanged

To connect it: The old app or the browser would call /api/invoice-preview in place of its own preview, and the service would read customers and products from the database in place of its built-in copy.

Our drawing, from the code in targets/python/.

Same order, both answers

The three orders you can try on the legacy app, sent to it and to this target on our machine on 23 October 2026. Every figure is what the program answered.

OrderThe legacy app POST /api/invoices/previewThe FastAPI service POST /api/invoice-previewVerdict
Aster Office Supplies100 × PAPER-A4931.00 + GST 83.79 = 1014.79931.00 + GST 83.79 = 1014.79Same answer
Merlion Retail10 × TONER-BK485.00 + GST 43.65 = 528.65Refused: HTTP 404, “Unknown productCode: TONER-BK”Different: refused
Straits Workshop3 × CABLE-USB, 101 × PAPER-A4999.81 + GST 89.98 = 1089.79999.81 + GST 89.98 = 1089.79Same answer

The same answer, field for field, for the two orders it can price. It refuses the third: it knows only two of the three products.

The same rule in Python, using exact decimals rather than floating point.

Before the legacy app · app/src/main/java/com/ingramtrials/base/Desk.java
BigDecimal previewLineNet(String tier, int qty, BigDecimal price) {
    BigDecimal net=price.multiply(BigDecimal.valueOf(qty));
    if ("A".equals(tier)) net=net.multiply(new BigDecimal("0.95"));
    if ("B".equals(tier)) net=net.multiply(new BigDecimal("0.97"));
    if (qty>=100) net=net.multiply(new BigDecimal("0.98"));
    return net.setScale(2,RoundingMode.HALF_UP);
}
After written by Bob · targets/python/app/main.py
line_gross = Decimal(qty) * up
after_tier = line_gross * (1 - tier_disc)
if qty >= BULK_THRESHOLD:
    after_tier = after_tier * (1 - BULK_DISCOUNT)
line_net = after_tier.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)

What a reviewer would raise. The service knows only two of the legacy app's three products: PAPER-A4 and CABLE-USB, the two the written contract's cases use. A preview with the black toner cartridge, TONER-BK, would come back as not found. A reviewer would add it, or read the products from the database.

Bob at work

From a recorded Bob session, trimmed to four moments. Bob worked from a written brief that listed what the code must do; the moments show how he carried it out. Our commentary is on the left.

  1. 1

    Bob reads the contract, including its awkward case. The contract records where the old application behaves oddly at the bulk-discount boundary, and states which total the new service must keep.

    read file inputs/invoice-preview-v1.json

    9 |   "preview_rule": "qty >= 100 receives the inclusive 2 percent bulk discount after the customer-tier discount",
    10 |   "known_compatibility_boundary": "The legacy save path differs at exactly qty=100; this preview contract intentionally retains total 1014.79.",
    40 |       "id": "qty=100",
    41 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 100, "unitPrice": "10.00"}]},
    42 |       "expected_status": 200,
    43 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "931.00", "gst": "83.79", "total": "1014.79"}
    All 107 lines
    1 | {
    2 |   "schema_version": 1,
    3 |   "contract_id": "invoice-preview-v1",
    4 |   "business_capability": "invoice-preview",
    5 |   "endpoint": "/api/invoice-preview",
    6 |   "health_endpoint": "/internal/health",
    7 |   "method": "POST",
    8 |   "content_type": "application/json",
    9 |   "preview_rule": "qty >= 100 receives the inclusive 2 percent bulk discount after the customer-tier discount",
    10 |   "known_compatibility_boundary": "The legacy save path differs at exactly qty=100; this preview contract intentionally retains total 1014.79.",
    11 |   "response_fields": [
    12 |     "customerId",
    13 |     "customerName",
    14 |     "tier",
    15 |     "subtotal",
    16 |     "gst",
    17 |     "total",
    18 |     "lines"
    19 |   ],
    20 |   "line_response_fields": [
    21 |     "lineNo",
    22 |     "productCode",
    23 |     "qty",
    24 |     "unitPrice",
    25 |     "lineNet"
    26 |   ],
    27 |   "health_requirements": {
    28 |     "status": "UP",
    29 |     "readOnly": true,
    30 |     "write_count_field": "writeCount"
    31 |   },
    32 |   "cases": [
    33 |     {
    34 |       "id": "qty=99",
    35 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 99, "unitPrice": "10.00"}]},
    36 |       "expected_status": 200,
    37 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.50", "gst": "84.65", "total": "1025.15"}
    38 |     },
    39 |     {
    40 |       "id": "qty=100",
    41 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 100, "unitPrice": "10.00"}]},
    42 |       "expected_status": 200,
    43 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "931.00", "gst": "83.79", "total": "1014.79"}
    44 |     },
    45 |     {
    46 |       "id": "qty=101",
    47 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 101, "unitPrice": "10.00"}]},
    48 |       "expected_status": 200,
    49 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.31", "gst": "84.63", "total": "1024.94"}
    50 |     },
    51 |     {
    52 |       "id": "rounding",
    53 |       "request": {"customerId": 1, "lines": [{"productCode": "CABLE-USB", "qty": 5, "unitPrice": "3.3350"}]},
    54 |       "expected_status": 200,
    55 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "15.84", "gst": "1.43", "total": "17.27"}
    56 |     },
    57 |     {
    58 |       "id": "tier=B",
    59 |       "request": {"customerId": 2, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    60 |       "expected_status": 200,
    61 |       "expected": {"customerName": "Merlion Retail", "tier": "B", "subtotal": "9.70", "gst": "0.87", "total": "10.57"}
    62 |     },
    63 |     {
    64 |       "id": "tier=C",
    65 |       "request": {"customerId": 3, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    66 |       "expected_status": 200,
    67 |       "expected": {"customerName": "Straits Workshop", "tier": "C", "subtotal": "10.00", "gst": "0.90", "total": "10.90"}
    68 |     },
    69 |     {
    70 |       "id": "caller-tier-ignored",
    71 |       "request": {"customerId": 1, "tier": "C", "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    72 |       "expected_status": 200,
    73 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "9.50", "gst": "0.86", "total": "10.36"}
    74 |     },
    75 |     {
    76 |       "id": "missing-customer",
    77 |       "request": {"lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    78 |       "expected_status": 400
    79 |     },
    80 |     {
    81 |       "id": "missing-lines",
    82 |       "request": {"customerId": 1},
    83 |       "expected_status": 400
    84 |     },
    85 |     {
    86 |       "id": "malformed-qty-string",
    87 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": "100", "unitPrice": "10.00"}]},
    88 |       "expected_status": 400
    89 |     },
    90 |     {
    91 |       "id": "malformed-qty-zero",
    92 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 0, "unitPrice": "10.00"}]},
    93 |       "expected_status": 400
    94 |     },
    95 |     {
    96 |       "id": "unknown-customer",
    97 |       "request": {"customerId": 999, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    98 |       "expected_status": 404
    99 |     },
    100 |     {
    101 |       "id": "unknown-product",
    102 |       "request": {"customerId": 1, "lines": [{"productCode": "UNKNOWN", "qty": 1, "unitPrice": "10.00"}]},
    103 |       "expected_status": 404
    104 |     }
    105 |   ]
    106 | }
    107 | 
  2. 2

    Bob checks the service against the brief. Before writing, he confirms each point: it trusts the caller's price tier, has no customer lookup and does not report unknown customers or products as not found.

    main.py failures: 1. InvoiceRequest has tier: str as a required field — must be optional (caller-supplied tier is ignored) 2. No customer lookup table — customerName is missing from response, tier must be resolved server-side 3. Unknown customer/product must return 404, not 400 4. Response missing customerName 5. Products must be validated (known set: PAPER-A4, CABLE-USB)
  3. 3

    Bob writes the service. The service now keeps its own customer table, takes the tier from it and answers not found for a customer it does not know.

    write file targets/python/app/main.py

    +# Customer registry — server-side resolution only; caller-supplied tier ignored
    +CUSTOMERS = {
    +    1: {"customerName": "Aster Office Supplies", "tier": "A"},
    +    # Resolve customer server-side; ignore any caller-supplied tier
    +    customer = CUSTOMERS.get(inv.customerId)
    +        return JSONResponse(status_code=404, content={"detail": f"Unknown customerId: {inv.customerId}"})
    All 110 lines
    Edited file: targets/python/app/main.py
    
    
    
    Patch of edit:
    
    <patch>
    @@ -1,11 +1,11 @@
     """
     Read-only FastAPI invoice-preview service.
     Contract: invoice-preview-v1
    -Profile:  phase2-fastapi-final-v1
    +Profile:  fastapi-legacy-v2
     """
     from decimal import Decimal, ROUND_HALF_UP
    -from typing import List
    +from typing import List, Optional
     
     from fastapi import FastAPI, Request
     from fastapi.exceptions import RequestValidationError
     from fastapi.responses import JSONResponse
    @@ -20,8 +20,22 @@
     GST_RATE = Decimal("0.09")
     TWO_PLACES = Decimal("0.01")
     
     # ---------------------------------------------------------------------------
    +# Customer registry — server-side resolution only; caller-supplied tier ignored
    +# ---------------------------------------------------------------------------
    +CUSTOMERS = {
    +    1: {"customerName": "Aster Office Supplies", "tier": "A"},
    +    2: {"customerName": "Merlion Retail",         "tier": "B"},
    +    3: {"customerName": "Straits Workshop",        "tier": "C"},
    +}
    +
    +# ---------------------------------------------------------------------------
    +# Known product codes — any other code is 404
    +# ---------------------------------------------------------------------------
    +KNOWN_PRODUCTS = {"PAPER-A4", "CABLE-USB"}
    +
    +# ---------------------------------------------------------------------------
     # Pydantic models — strict JSON types enforced via validators
     # ---------------------------------------------------------------------------
     
     class LineItem(BaseModel):
    @@ -56,9 +70,9 @@
     
     
     class InvoiceRequest(BaseModel):
         customerId: int
    -    tier: str
    +    tier: Optional[str] = None  # caller-supplied tier is accepted but ignored
         lines: List[LineItem]
     
         @model_validator(mode="before")
         @classmethod
    @@ -70,16 +84,9 @@
             if isinstance(values.get("lines"), list) and len(values["lines"]) == 0:
                 raise ValueError("lines must not be empty")
             return values
     
    -    @field_validator("tier", mode="before")
    -    @classmethod
    -    def tier_must_be_valid(cls, v):
    -        if v not in TIER_DISCOUNT:
    -            raise ValueError("tier must be A, B, or C")
    -        return v
     
    -
     # ---------------------------------------------------------------------------
     # Application
     # ---------------------------------------------------------------------------
     app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
    @@ -118,9 +125,22 @@
             inv = InvoiceRequest.model_validate(body)
         except Exception as exc:
             return JSONResponse(status_code=400, content={"detail": str(exc)})
     
    -    tier_disc = TIER_DISCOUNT[inv.tier]
    +    # Resolve customer server-side; ignore any caller-supplied tier
    +    customer = CUSTOMERS.get(inv.customerId)
    +    if customer is None:
    +        return JSONResponse(status_code=404, content={"detail": f"Unknown customerId: {inv.customerId}"})
    +
    +    customer_name = customer["customerName"]
    +    tier = customer["tier"]
    +    tier_disc = TIER_DISCOUNT[tier]
    +
    +    # Validate all product codes before computing any line
    +    for line in inv.lines:
    +        if line.productCode not in KNOWN_PRODUCTS:
    +            return JSONResponse(status_code=404, content={"detail": f"Unknown productCode: {line.productCode}"})
    +
         lines_out = []
         subtotal = Decimal("0")
     
         for i, line in enumerate(inv.lines, start=1):
    @@ -147,9 +167,10 @@
         total = (subtotal + gst).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
     
         return {
             "customerId": inv.customerId,
    -        "tier": inv.tier,
    +        "customerName": customer_name,
    +        "tier": tier,
             "subtotal": str(subtotal),
             "gst": str(gst),
             "total": str(total),
             "lines": lines_out,
    
    </patch>
  4. 4

    Bob writes tests for the cases that matter most. His tests prove that a caller cannot pick a cheaper tier, and that unknown customers and products are refused.

    write file targets/python/tests/test_preview.py

    +    def test_mismatched_tier_injection_ignored(self):
    +        r = self._post({"customerId": 1, "tier": "C",
    +        self.assertEqual(body["tier"], "A")
    +        self.assertEqual(body["total"], "10.36")
    +    def test_unknown_customer_is_404(self):
    +        self.assertEqual(r.status_code, 404)
    All 303 lines
    Edited file: targets/python/tests/test_preview.py
    
    
    
    Patch of edit:
    
    <patch>
    @@ -1,8 +1,8 @@
     """
     Tests for invoice-preview-v1 FastAPI service.
     Run: python -m unittest discover -s tests -v
    -Requires: fastapi==0.143.1, uvicorn==0.54.0 (via requirements.txt)
    +Requires: fastapi==0.143.1, uvicorn==0.54.0, httpx==0.28.1 (via requirements.txt)
     No pytest dependency.
     """
     import json
     import sys
    @@ -50,23 +50,53 @@
                 headers={"Content-Type": "application/json"},
             )
     
         def test_top_level_fields(self):
    -        r = self._post({"customerId": 1, "tier": "A",
    -                        "lines": [{"productCode": "P", "qty": 1, "unitPrice": "10.00"}]})
    +        r = self._post({"customerId": 1,
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 200)
             self.assertEqual(set(r.json().keys()),
    -                         {"customerId", "tier", "subtotal", "gst", "total", "lines"})
    +                         {"customerId", "customerName", "tier", "subtotal", "gst", "total", "lines"})
     
         def test_line_fields(self):
    -        r = self._post({"customerId": 1, "tier": "A",
    -                        "lines": [{"productCode": "P", "qty": 1, "unitPrice": "10.00"}]})
    +        r = self._post({"customerId": 1,
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 200)
             line = r.json()["lines"][0]
             self.assertEqual(set(line.keys()),
                              {"lineNo", "productCode", "qty", "unitPrice", "lineNet"})
     
     
    +class TestExactFullResponse(unittest.TestCase):
    +    """Full response body matches contract case exactly."""
    +
    +    def _post(self, payload):
    +        return client.post(
    +            "/api/invoice-preview",
    +            content=json.dumps(payload),
    +            headers={"Content-Type": "application/json"},
    +        )
    +
    +    def test_full_response_qty99(self):
    +        r = self._post({"customerId": 1,
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 99, "unitPrice": "10.00"}]})
    +        self.assertEqual(r.status_code, 200)
    +        body = r.json()
    +        self.assertEqual(body["customerId"], 1)
    +        self.assertEqual(body["customerName"], "Aster Office Supplies")
    +        self.assertEqual(body["tier"], "A")
    +        self.assertEqual(body["subtotal"], "940.50")
    +        self.assertEqual(body["gst"], "84.65")
    +        self.assertEqual(body["total"], "1025.15")
    +        self.assertEqual(len(body["lines"]), 1)
    +        line = body["lines"][0]
    +        self.assertEqual(line["lineNo"], 1)
    +        self.assertEqual(line["productCode"], "PAPER-A4")
    +        self.assertEqual(line["qty"], 99)
    +        self.assertEqual(line["unitPrice"], "10.00")
    +        self.assertEqual(line["lineNet"], "940.50")
    +
    +
     class TestTotals(unittest.TestCase):
         """Contract totals — preserved exactly per invoice-preview-v1.json cases."""
     
         def _post(self, payload):
    @@ -76,62 +106,140 @@
                 headers={"Content-Type": "application/json"},
             )
     
         def test_qty99_total(self):
    -        r = self._post({"customerId": 1, "tier": "A",
    +        r = self._post({"customerId": 1,
                             "lines": [{"productCode": "PAPER-A4", "qty": 99, "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 200)
             body = r.json()
    +        self.assertEqual(body["customerName"], "Aster Office Supplies")
    +        self.assertEqual(body["tier"], "A")
             self.assertEqual(body["subtotal"], "940.50")
             self.assertEqual(body["gst"], "84.65")
             self.assertEqual(body["total"], "1025.15")
     
         def test_qty100_total(self):
    -        r = self._post({"customerId": 1, "tier": "A",
    +        r = self._post({"customerId": 1,
                             "lines": [{"productCode": "PAPER-A4", "qty": 100, "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 200)
             body = r.json()
    +        self.assertEqual(body["customerName"], "Aster Office Supplies")
    +        self.assertEqual(body["tier"], "A")
             self.assertEqual(body["subtotal"], "931.00")
             self.assertEqual(body["gst"], "83.79")
             self.assertEqual(body["total"], "1014.79")
     
         def test_qty101_total(self):
    -        r = self._post({"customerId": 1, "tier": "A",
    +        r = self._post({"customerId": 1,
                             "lines": [{"productCode": "PAPER-A4", "qty": 101, "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 200)
             body = r.json()
    +        self.assertEqual(body["customerName"], "Aster Office Supplies")
    +        self.assertEqual(body["tier"], "A")
             self.assertEqual(body["subtotal"], "940.31")
             self.assertEqual(body["gst"], "84.63")
             self.assertEqual(body["total"], "1024.94")
     
         def test_rounding_5x3335(self):
    -        r = self._post({"customerId": 1, "tier": "A",
    +        r = self._post({"customerId": 1,
                             "lines": [{"productCode": "CABLE-USB", "qty": 5, "unitPrice": "3.3350"}]})
             self.assertEqual(r.status_code, 200)
             body = r.json()
    +        self.assertEqual(body["customerName"], "Aster Office Supplies")
    +        self.assertEqual(body["tier"], "A")
             self.assertEqual(body["subtotal"], "15.84")
             self.assertEqual(body["gst"], "1.43")
             self.assertEqual(body["total"], "17.27")
     
         def test_tier_b(self):
    -        r = self._post({"customerId": 2, "tier": "B",
    +        r = self._post({"customerId": 2,
                             "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 200)
             body = r.json()
    +        self.assertEqual(body["customerName"], "Merlion Retail")
    +        self.assertEqual(body["tier"], "B")
             self.assertEqual(body["subtotal"], "9.70")
             self.assertEqual(body["gst"], "0.87")
             self.assertEqual(body["total"], "10.57")
     
         def test_tier_c(self):
    -        r = self._post({"customerId": 3, "tier": "C",
    +        r = self._post({"customerId": 3,
                             "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 200)
             body = r.json()
    +        self.assertEqual(body["customerName"], "Straits Workshop")
    +        self.assertEqual(body["tier"], "C")
             self.assertEqual(body["subtotal"], "10.00")
             self.assertEqual(body["gst"], "0.90")
             self.assertEqual(body["total"], "10.90")
     
     
    +class TestCallerTierIgnored(unittest.TestCase):
    +    """Caller-supplied tier must be silently ignored; server resolves tier from customerId."""
    +
    +    def _post(self, payload):
    +        return client.post(
    +            "/api/invoice-preview",
    +            content=json.dumps(payload),
    +            headers={"Content-Type": "application/json"},
    +        )
    +
    +    def test_no_tier_in_payload_uses_server_tier(self):
    +        """No-tier legacy payload: customerId 1 resolves to tier A."""
    +        r = self._post({"customerId": 1,
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
    +        self.assertEqual(r.status_code, 200)
    +        body = r.json()
    +        self.assertEqual(body["tier"], "A")
    +        self.assertEqual(body["customerName"], "Aster Office Supplies")
    +        self.assertEqual(body["subtotal"], "9.50")
    +        self.assertEqual(body["gst"], "0.86")
    +        self.assertEqual(body["total"], "10.36")
    +
    +    def test_mismatched_tier_injection_ignored(self):
    +        """Caller injects tier=C for customerId 1 (tier A); server returns tier A totals."""
    +        r = self._post({"customerId": 1, "tier": "C",
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
    +        self.assertEqual(r.status_code, 200)
    +        body = r.json()
    +        # Server resolves to A, not the caller's injected C
    +        self.assertEqual(body["tier"], "A")
    +        self.assertEqual(body["customerName"], "Aster Office Supplies")
    +        self.assertEqual(body["subtotal"], "9.50")
    +        self.assertEqual(body["gst"], "0.86")
    +        self.assertEqual(body["total"], "10.36")
    +
    +    def test_tier_a_in_payload_still_uses_server_tier(self):
    +        """Caller supplies matching tier A for customerId 1 — server still resolves independently."""
    +        r = self._post({"customerId": 1, "tier": "A",
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
    +        self.assertEqual(r.status_code, 200)
    +        body = r.json()
    +        self.assertEqual(body["tier"], "A")
    +        self.assertEqual(body["customerName"], "Aster Office Supplies")
    +
    +
    +class TestNotFound(unittest.TestCase):
    +    """Unknown customer or product → HTTP 404."""
    +
    +    def _post(self, payload):
    +        return client.post(
    +            "/api/invoice-preview",
    +            content=json.dumps(payload),
    +            headers={"Content-Type": "application/json"},
    +        )
    +
    +    def test_unknown_customer_is_404(self):
    +        r = self._post({"customerId": 999,
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
    +        self.assertEqual(r.status_code, 404)
    +
    +    def test_unknown_product_is_404(self):
    +        r = self._post({"customerId": 1,
    +                        "lines": [{"productCode": "UNKNOWN", "qty": 1, "unitPrice": "10.00"}]})
    +        self.assertEqual(r.status_code, 404)
    +
    +
     class TestValidationErrors(unittest.TestCase):
         """Invalid input → HTTP 400."""
     
         def _post(self, payload):
    @@ -148,45 +256,39 @@
                 headers={"Content-Type": "application/json"},
             )
     
         def test_missing_customer_id(self):
    -        r = self._post({"tier": "A",
    -                        "lines": [{"productCode": "P", "qty": 1, "unitPrice": "10.00"}]})
    +        r = self._post({"lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 400)
     
         def test_missing_lines(self):
    -        r = self._post({"customerId": 1, "tier": "A"})
    +        r = self._post({"customerId": 1})
             self.assertEqual(r.status_code, 400)
     
         def test_empty_lines(self):
    -        r = self._post({"customerId": 1, "tier": "A", "lines": []})
    +        r = self._post({"customerId": 1, "lines": []})
             self.assertEqual(r.status_code, 400)
     
    -    def test_invalid_tier(self):
    -        r = self._post({"customerId": 1, "tier": "Z",
    -                        "lines": [{"productCode": "P", "qty": 1, "unitPrice": "10.00"}]})
    -        self.assertEqual(r.status_code, 400)
    -
         def test_qty_string_is_400(self):
    -        r = self._post({"customerId": 1, "tier": "A",
    -                        "lines": [{"productCode": "P", "qty": "100", "unitPrice": "10.00"}]})
    +        r = self._post({"customerId": 1,
    +                        "lines": [{"productCode": "PAPER-A4", "qty": "100", "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 400)
     
         def test_qty_zero_is_400(self):
    -        r = self._post({"customerId": 1, "tier": "A",
    -                        "lines": [{"productCode": "P", "qty": 0, "unitPrice": "10.00"}]})
    +        r = self._post({"customerId": 1,
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 0, "unitPrice": "10.00"}]})
             self.assertEqual(r.status_code, 400)
     
         def test_qty_fractional_float_is_400(self):
             # JSON fractional number for qty
             r = self._post_raw(
    -            '{"customerId": 1, "tier": "A", "lines": [{"productCode": "P", "qty": 1.5, "unitPrice": "10.00"}]}'
    +            '{"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 1.5, "unitPrice": "10.00"}]}'
             )
             self.assertEqual(r.status_code, 400)
     
         def test_unit_price_non_positive_is_400(self):
    -        r = self._post({"customerId": 1, "tier": "A",
    -                        "lines": [{"productCode": "P", "qty": 1, "unitPrice": "0.00"}]})
    +        r = self._post({"customerId": 1,
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "0.00"}]})
             self.assertEqual(r.status_code, 400)
     
         def test_malformed_json_is_400(self):
             r = self._post_raw("{not valid json}")
    @@ -227,17 +329,17 @@
                 headers={"Content-Type": "application/json"},
             )
     
         def test_write_count_stays_zero_after_preview(self):
    -        self._post({"customerId": 1, "tier": "A",
    -                    "lines": [{"productCode": "P", "qty": 1, "unitPrice": "10.00"}]})
    +        self._post({"customerId": 1,
    +                    "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
             r = client.get("/internal/health")
             self.assertEqual(r.json()["writeCount"], 0)
     
         def test_write_count_stays_zero_after_multiple_previews(self):
             for _ in range(3):
    -            self._post({"customerId": 1, "tier": "A",
    -                        "lines": [{"productCode": "P", "qty": 1, "unitPrice": "10.00"}]})
    +            self._post({"customerId": 1,
    +                        "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]})
             r = client.get("/internal/health")
             self.assertEqual(r.json()["writeCount"], 0)
     
     
    
    </patch>

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: build and test it yourself

From the repository root

cd targets/python
python -m venv .venv
.venv\Scripts\python -m pip install -r requirements.txt
.venv\Scripts\python -m unittest discover -s tests -v

The code and its build notes are in targets/python/.

Back to the seven migrations

Migration walkthrough · one feature, not a full migration

.NET (ASP.NET Core)

What moved: invoice preview rebuilt as an ASP.NET Core minimal API.

What the assessment said, before anything was built

not recommended as the primary path; viable if the client standard is .NET; full rewrite

A judgement from reading the code: no trial build was run at assessment.

How it was checked

Bob wrote the code and its tests, but had no way to run them in his session. We ran them when this repository was put together: Passed! - Failed: 0, Passed: 32.

These are Bob's own tests. They show the feature gives the legacy answers on the cases Bob wrote; they do not prove every case.

Before and after

Before Try the legacy app

  • Browser the Invoice Desk screen
  • Java 8 app works out the preview in Desk.calculate()
  • H2 database customers, their price tiers and products

In the code that moved, Bob's assessment found:

  • F-004 high Preview/save bulk-discount off-by-one: qty=100 discounted in preview but not in save Kept: the service still discounts at 100 items, and its tests check 99 and 100.
  • F-007 low 50-line invoice limit enforced server-side only; browser UI provides no feedback Not just unchecked in the browser now: the service has no line limit at all.

After Built and tested on its own

  • Browser unchanged
  • Java 8 app unchanged
  • New, under the Java 8 app: ASP.NET Core service its own copy of the customers and two products not connected in this demo
  • H2 database unchanged

To connect it: The old app or the browser would call /api/invoice-preview in place of its own preview, and the service would read customers and products from the database in place of its built-in copy.

Our drawing, from the code in targets/dotnet/.

Same order, both answers

The three orders you can try on the legacy app, sent to it and to this target on our machine on 23 October 2026. Every figure is what the program answered.

OrderThe legacy app POST /api/invoices/previewThe ASP.NET Core service POST /api/invoice-previewVerdict
Aster Office Supplies100 × PAPER-A4931.00 + GST 83.79 = 1014.79931.00 + GST 83.79 = 1014.79Same answer
Merlion Retail10 × TONER-BK485.00 + GST 43.65 = 528.65Refused: HTTP 404, “Product not found: TONER-BK”Different: refused
Straits Workshop3 × CABLE-USB, 101 × PAPER-A4999.81 + GST 89.98 = 1089.79999.81 + GST 89.98 = 1089.79Same answer

The same answer, field for field, for the two orders it can price. It refuses the third: it knows only two of the three products.

The same rule in C#: tier discount, then the bulk discount from quantity 100, rounded to two places.

Before the legacy app · app/src/main/java/com/ingramtrials/base/Desk.java
BigDecimal previewLineNet(String tier, int qty, BigDecimal price) {
    BigDecimal net=price.multiply(BigDecimal.valueOf(qty));
    if ("A".equals(tier)) net=net.multiply(new BigDecimal("0.95"));
    if ("B".equals(tier)) net=net.multiply(new BigDecimal("0.97"));
    if (qty>=100) net=net.multiply(new BigDecimal("0.98"));
    return net.setScale(2,RoundingMode.HALF_UP);
}
After written by Bob · targets/dotnet/Program.cs
decimal discounted = unitPrice * (1m - tierDiscount);
if (qty >= 100)
    discounted = discounted * 0.98m;

decimal lineNet = decimal.Round(discounted * qty, 2, MidpointRounding.AwayFromZero);

What a reviewer would raise. The service knows only two of the legacy app's three products: PAPER-A4 and CABLE-USB, the two the written contract's cases use. A preview with the black toner cartridge, TONER-BK, would come back as not found. A reviewer would add it, or read the products from the database.

Bob at work

From a recorded Bob session, trimmed to four moments. Bob worked from a written brief that listed what the code must do; the moments show how he carried it out. Our commentary is on the left.

  1. 1

    Bob reads what the contract says about unknown requests. Besides the expected totals, the contract says an unknown customer or product must come back as not found rather than as a bad request.

    read file inputs/invoice-preview-v1.json

    96 |       "id": "unknown-customer",
    97 |       "request": {"customerId": 999, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    98 |       "expected_status": 404
    101 |       "id": "unknown-product",
    102 |       "request": {"customerId": 1, "lines": [{"productCode": "UNKNOWN", "qty": 1, "unitPrice": "10.00"}]},
    103 |       "expected_status": 404
    All 107 lines
    1 | {
    2 |   "schema_version": 1,
    3 |   "contract_id": "invoice-preview-v1",
    4 |   "business_capability": "invoice-preview",
    5 |   "endpoint": "/api/invoice-preview",
    6 |   "health_endpoint": "/internal/health",
    7 |   "method": "POST",
    8 |   "content_type": "application/json",
    9 |   "preview_rule": "qty >= 100 receives the inclusive 2 percent bulk discount after the customer-tier discount",
    10 |   "known_compatibility_boundary": "The legacy save path differs at exactly qty=100; this preview contract intentionally retains total 1014.79.",
    11 |   "response_fields": [
    12 |     "customerId",
    13 |     "customerName",
    14 |     "tier",
    15 |     "subtotal",
    16 |     "gst",
    17 |     "total",
    18 |     "lines"
    19 |   ],
    20 |   "line_response_fields": [
    21 |     "lineNo",
    22 |     "productCode",
    23 |     "qty",
    24 |     "unitPrice",
    25 |     "lineNet"
    26 |   ],
    27 |   "health_requirements": {
    28 |     "status": "UP",
    29 |     "readOnly": true,
    30 |     "write_count_field": "writeCount"
    31 |   },
    32 |   "cases": [
    33 |     {
    34 |       "id": "qty=99",
    35 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 99, "unitPrice": "10.00"}]},
    36 |       "expected_status": 200,
    37 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.50", "gst": "84.65", "total": "1025.15"}
    38 |     },
    39 |     {
    40 |       "id": "qty=100",
    41 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 100, "unitPrice": "10.00"}]},
    42 |       "expected_status": 200,
    43 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "931.00", "gst": "83.79", "total": "1014.79"}
    44 |     },
    45 |     {
    46 |       "id": "qty=101",
    47 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 101, "unitPrice": "10.00"}]},
    48 |       "expected_status": 200,
    49 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "940.31", "gst": "84.63", "total": "1024.94"}
    50 |     },
    51 |     {
    52 |       "id": "rounding",
    53 |       "request": {"customerId": 1, "lines": [{"productCode": "CABLE-USB", "qty": 5, "unitPrice": "3.3350"}]},
    54 |       "expected_status": 200,
    55 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "15.84", "gst": "1.43", "total": "17.27"}
    56 |     },
    57 |     {
    58 |       "id": "tier=B",
    59 |       "request": {"customerId": 2, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    60 |       "expected_status": 200,
    61 |       "expected": {"customerName": "Merlion Retail", "tier": "B", "subtotal": "9.70", "gst": "0.87", "total": "10.57"}
    62 |     },
    63 |     {
    64 |       "id": "tier=C",
    65 |       "request": {"customerId": 3, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    66 |       "expected_status": 200,
    67 |       "expected": {"customerName": "Straits Workshop", "tier": "C", "subtotal": "10.00", "gst": "0.90", "total": "10.90"}
    68 |     },
    69 |     {
    70 |       "id": "caller-tier-ignored",
    71 |       "request": {"customerId": 1, "tier": "C", "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    72 |       "expected_status": 200,
    73 |       "expected": {"customerName": "Aster Office Supplies", "tier": "A", "subtotal": "9.50", "gst": "0.86", "total": "10.36"}
    74 |     },
    75 |     {
    76 |       "id": "missing-customer",
    77 |       "request": {"lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    78 |       "expected_status": 400
    79 |     },
    80 |     {
    81 |       "id": "missing-lines",
    82 |       "request": {"customerId": 1},
    83 |       "expected_status": 400
    84 |     },
    85 |     {
    86 |       "id": "malformed-qty-string",
    87 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": "100", "unitPrice": "10.00"}]},
    88 |       "expected_status": 400
    89 |     },
    90 |     {
    91 |       "id": "malformed-qty-zero",
    92 |       "request": {"customerId": 1, "lines": [{"productCode": "PAPER-A4", "qty": 0, "unitPrice": "10.00"}]},
    93 |       "expected_status": 400
    94 |     },
    95 |     {
    96 |       "id": "unknown-customer",
    97 |       "request": {"customerId": 999, "lines": [{"productCode": "PAPER-A4", "qty": 1, "unitPrice": "10.00"}]},
    98 |       "expected_status": 404
    99 |     },
    100 |     {
    101 |       "id": "unknown-product",
    102 |       "request": {"customerId": 1, "lines": [{"productCode": "UNKNOWN", "qty": 1, "unitPrice": "10.00"}]},
    103 |       "expected_status": 404
    104 |     }
    105 |   ]
    106 | }
    107 | 
  2. 2

    Bob plans the changes to the ASP.NET Core service. He sets out each change before writing: a customer table on the server, the tier taken from it, and not found for unknown customers and products.

    Program.cs changes: 1. Add customer lookup table (id→name+tier) 2. Remove tier from InvoiceRequest (or keep it as ignorable field — cleaner to keep parsing but ignore it) 3. Remove tier validation from Validate() 4. Add 404 for unknown customerId in the handler 5. Add 404 for unknown productCode (only PAPER-A4 and CABLE-USB are known products from contract) 6. Add customerName to PreviewResponse 7. Resolve tier from customer table in Preview.Calculate (pass customer record)
  3. 3

    Bob sets out what the tests must prove. Some tests still send a price tier on purpose, to prove the service ignores it; new ones cover the unknown customer and product.

    Now write the updated test file. Key changes: Remove tier from request bodies (server resolves it) — but keep it in a few tests to confirm it is ignored Add customerName assertion where response fields are verified Remove Preview_InvalidTier_Returns400 (tier no longer validated) Add: NoTierLegacyPayload_Returns200, CallerTierIgnored_UsesServerTier, UnknownCustomer_Returns404, UnknownProduct_Returns404, ResponseContains_CustomerName
  4. 4

    Bob reads the code back and checks a case by hand. He reads the finished catalogue code, then works through the case where a caller asks for the wrong tier and confirms the total matches the contract.

    All five declared output files are now written. Let me do a final cross-check to confirm correctness of the key logic paths.

    read file targets/dotnet/Program.cs

    63 | // ── Customer catalog ──────────────────────────────────────────────────────────
    65 | record Customer(int Id, string Name, string Tier);
    67 | static class CustomerCatalog
    71 |         [1] = new Customer(1, "Aster Office Supplies", "A"),
    82 | static class ProductCatalog
    90 |     internal static bool IsKnown(string? code)
    All 136 lines
    61 | app.Run();
    62 | 
    63 | // ── Customer catalog ──────────────────────────────────────────────────────────
    64 | 
    65 | record Customer(int Id, string Name, string Tier);
    66 | 
    67 | static class CustomerCatalog
    68 | {
    69 |     private static readonly Dictionary<int, Customer> _map = new()
    70 |     {
    71 |         [1] = new Customer(1, "Aster Office Supplies", "A"),
    72 |         [2] = new Customer(2, "Merlion Retail",        "B"),
    73 |         [3] = new Customer(3, "Straits Workshop",      "C"),
    74 |     };
    75 | 
    76 |     internal static bool TryGet(int id, out Customer customer)
    77 |         => _map.TryGetValue(id, out customer!);
    78 | }
    79 | 
    80 | // ── Product catalog ───────────────────────────────────────────────────────────
    81 | 
    82 | static class ProductCatalog
    83 | {
    84 |     private static readonly HashSet<string> _known = new(StringComparer.OrdinalIgnoreCase)
    85 |     {
    86 |         "PAPER-A4",
    87 |         "CABLE-USB",
    88 |     };
    89 | 
    90 |     internal static bool IsKnown(string? code)
    91 |         => code is not null && _known.Contains(code);
    92 | }
    93 | 
    94 | // ── Domain / request types ────────────────────────────────────────────────────
    95 | 
    96 | record HealthResponse(
    97 |     [property: JsonPropertyName("status")]   string Status,
    98 |     [property: JsonPropertyName("readOnly")] bool ReadOnly,
    99 |     [property: JsonPropertyName("writeCount")] int WriteCount);
    100 | 
    101 | record InvoiceRequest(
    102 |     [property: JsonPropertyName("customerId")] int?           CustomerId,
    103 |     [property: JsonPropertyName("tier")]        string?        Tier,
    104 |     [property: JsonPropertyName("lines")]       List<LineItem>? Lines);
    105 | 
    106 | record LineItem(
    107 |     [property: JsonPropertyName("lineNo")]      int?    LineNo,
    108 |     [property: JsonPropertyName("productCode")] string? ProductCode,
    109 |     [property: JsonPropertyName("qty")]         int?    Qty,
    110 |     [property: JsonPropertyName("unitPrice")]   string? UnitPrice);
    111 | 
    112 | // ── Response types ────────────────────────────────────────────────────────────
    113 | 
    114 | record PreviewResponse(
    115 |     [property: JsonPropertyName("customerId")]   int    CustomerId,
    116 |     [property: JsonPropertyName("customerName")] string CustomerName,
    117 |     [property: JsonPropertyName("tier")]          string Tier,
    118 |     [property: JsonPropertyName("subtotal")]      string Subtotal,
    119 |     [property: JsonPropertyName("gst")]           string Gst,
    120 |     [property: JsonPropertyName("total")]         string Total,
    121 |     [property: JsonPropertyName("lines")]         List<LineDto> Lines);
    122 | 
    123 | record LineDto(
    124 |     [property: JsonPropertyName("lineNo")]      int    LineNo,
    125 |     [property: JsonPropertyName("productCode")] string ProductCode,
    126 |     [property: JsonPropertyName("qty")]         int    Qty,
    127 |     [property: JsonPropertyName("unitPrice")]   string UnitPrice,
    128 |     [property: JsonPropertyName("lineNet")]     string LineNet);
    129 | 
    130 | // ── Validation ────────────────────────────────────────────────────────────────
    131 | 
    132 | static class InvoiceRequestExtensions
    133 | {
    134 |     internal static string? Validate(this InvoiceRequest req)
    135 |     {
    136 |         if (req.CustomerId is null)
    137 |             return "customerId is required";
    138 |         if (req.Lines is null || req.Lines.Count == 0)
    139 |             return "lines are required";
    140 |         foreach (var line in req.Lines)
    141 |         {
    142 |             if (line.Qty is null || line.Qty.Value <= 0)
    143 |                 return "qty must be a positive integer";
    144 |             if (line.UnitPrice is null ||
    145 |                 !decimal.TryParse(line.UnitPrice,
    146 |                     System.Globalization.NumberStyles.Number,
    147 |                     System.Globalization.CultureInfo.InvariantCulture,
    148 |                     out var up) || up <= 0m)
    149 |                 return "unitPrice must be a positive decimal string";
    150 |         }
    151 |         return null;
    152 |     }
    153 | }
    154 | 
    155 | // ── Pricing engine ────────────────────────────────────────────────────────────
    156 | 
    157 | static class Preview
    158 | {
    159 |     private static readonly decimal GstRate = 0.09m;
    160 | 
    161 |     internal static PreviewResponse Calculate(InvoiceRequest req, Customer customer)
    162 |     {
    163 |         decimal tierDiscount = customer.Tier switch
    164 |         {
    165 |             "A" => 0.05m,
    166 |             "B" => 0.03m,
    167 |             _   => 0.00m
    168 |         };
    169 | 
    170 |         var lines = new List<LineDto>();
    171 |         int lineNo = 1;
    172 |         decimal subtotal = 0m;
    173 | 
    174 |         foreach (var item in req.Lines!)
    175 |         {
    176 |             int qty = item.Qty!.Value;
    177 |             decimal unitPrice = decimal.Parse(
    178 |                 item.UnitPrice!,
    179 |                 System.Globalization.NumberStyles.Number,
    180 |                 System.Globalization.CultureInfo.InvariantCulture);
    181 | 
    182 |             decimal discounted = unitPrice * (1m - tierDiscount);
    183 |             if (qty >= 100)
    184 |                 discounted = discounted * 0.98m;
    185 | 
    186 |             decimal lineNet = decimal.Round(discounted * qty, 2, MidpointRounding.AwayFromZero);
    187 |             subtotal += lineNet;
    188 | 
    189 |             lines.Add(new LineDto(
    190 |                 lineNo++,
    191 |                 item.ProductCode ?? "",
    192 |                 qty,
    193 |                 item.UnitPrice!,
    194 |                 lineNet.ToString("F2")));
    195 |         }
    196 | 
    The code is correct. Let me verify one spot-check: the caller-tier-ignored case. Contract says customerId=1, tier=C in request → expected subtotal=9.50 (tier A = 5% off). With server-side resolution: tier A, unitPrice=10.00, qty=1, no bulk. discounted = 10.00 × (1 - 0.05) = 9.50. lineNet = 9.50. subtotal = 9.50. gst = Round(9.50 × 0.09, 2) = Round(0.855, 2) = 0.86. total = 9.50 + 0.86 = 10.36. ✓ Matches contract.

Recorded Bob session, replayed. Paths are shortened, long output is collapsed, internal run references are hidden and the editing tool's own notices are left out; Bob's words are unchanged, and his markdown is shown as formatting.

Go deeper: build and test it yourself

From the repository root

dotnet build targets/dotnet/PreviewApi.csproj --configuration Release --nologo
dotnet test targets/dotnet/tests/PreviewApi.Tests/PreviewApi.Tests.csproj --configuration Release --nologo

The code and its build notes are in targets/dotnet/.

Back to the four documents

Bob's document · written by IBM Bob

Migration assessment

Findings, debt scores and the ways forward, with Bob's recommendation. His annexes on the seven migration paths follow the main report.

Migration assessmentEffort modelImplementation planStatement of work

Bob wrote this inside our test setup. Our automatic checks hold back 10 sentences and 6 table cells, each place marked: most mention that setup, a few only share a word with it. The file's metadata block is left out. Every other word is Bob's, unchanged.

Recommendation and decision

Recommendation: bounded migration with H2-to-PostgreSQL database replacement and Spring Boot 3 / Java 21 upgrade as the minimum viable modernisation path, with a separately authorized Java invoice-preview pilot before committing to full migration scope.

The BASE application (com.ingramtrials.base, Spring Boot 2.7.18, Java 8, H2 file-mode) is a functioning single-JAR financial desk with 23 confirmed findings across security, data integrity, architecture and observability dimensions. Three security findings (F-001 SQL injection, F-002 credential exposure, F-003 unauthenticated batch triggers) and four high-severity structural findings (F-005 batch atomicity, F-016 H2-specific SQL, F-017 EOL dependencies, F-021 no backup/DR) make the codebase unsuitable for production deployment without remediation regardless of the migration path chosen.

The retain/contain option avoids migration disruption but does not resolve the EOL Spring Boot 2.7.x exposure, the SQL injection defects, or the absence of a backup regime. Bounded migration (H2 → PostgreSQL + Spring Boot 3 upgrade, with the existing Java architecture retained) addresses the highest-severity findings with lower delivery risk than a full rewrite. [a sentence held back]

Decision owner: Client technical and security owner (pending). Decision required: Confirm the migration target profile, authorize the Java invoice-preview pilot (separately gated), and provide a backup/DR policy decision before delivery scoping can proceed. Blockers: F-001 (SQL injection must be fixed regardless of path), F-005 (batch atomicity), F-017 (EOL dependency upgrade), F-021 (backup/DR policy), F-009 (InvoiceRunJobV2 owner disposition — owner and activation path unknown; see EV-004, EV-014, EV-030). Evidence that could change the recommendation: A confirmed production APM trace showing high concurrent load would increase the urgency of the synchronized-method architecture finding (F-012) and potentially justify an earlier microservice extraction. A confirmed CVE scan result matching an exploitable path in Spring Boot 2.7.18 would escalate F-017 to critical and compress the upgrade timeline.

Pilot boundary: The separately authorized Java invoice-preview pilot covers the Desk.calculate() path (preview only, no save or batch operations), with target Java 21 and Spring Boot 3 compatibility. Scope exclusions: database migration, job paths, authentication, browser SPA, and deployment to any production environment. [a sentence held back] Production deployment, security clearance and enterprise performance remain unverified.

[a sentence held back] Security findings F-001, F-002 and F-003 must be remediated and verified before any production exposure. Assessment status: pending independent review.


Current state and inspected evidence

Component / entry pointSource path or runEvidence ID / classObserved factUncertainty / owner action
H2 datasourceapplication.properties:3EV-001 / source_configFile-mode H2 at ./runtime/data/base; no production RDBMS configuredNo backup/DR config; data volume unknown (EV-028)
Demo date / schedulesapplication.properties:7-10EV-002 / source_configdemo.today hardcoded 2026-10-13; schedules disabled by defaultWhether schedules are enabled in any deployed environment is unknown (EV-031)
Server bindingapplication.properties:1-2EV-003 / source_configLoopback 127.0.0.1:18080; no external binding in sourceNo reverse-proxy or network topology documentation
Job registryjobs.properties:2-3EV-004 / source_config'invoices'→InvoiceRunJob; 'credit-notes'→CreditNoteJob; InvoiceRunJobV2 not registeredInvoiceRunJobV2 owner/disposition unknown (EV-030)
Desk @PostConstructDesk.java:36-59EV-005, EV-006 / source_config8 DDL statements + seed inserts at startup; jobs.properties loaded; missing resource throws ISENo migration framework; no column evolution path
customers() searchDesk.java:93EV-007 / source_configSQL injection via string-concatenated LIKE on q paramNot tested; no parameterized query present
invoices() searchDesk.java:129-132EV-008 / source_configSame SQL injection pattern as customers()Not tested
preview vs save discountDesk.java:107-119EV-009 / source_configpreviewLineNet uses >=100; bookedLineNet uses >100 — off-by-one at qty=100Business owner decision on intended boundary pending
Job dispatchDesk.java:220-225EV-010 / source_configClass.forName().newInstance() (deprecated since Java 9)No compile-time binding; runtime failure on bad class name
save() / requestCredit() lockingDesk.java:177, :194EV-011, EV-012 / source_configsynchronized on Desk instance; COALESCE MAX+1 ID generationSingle-JVM safe; breaks under horizontal scaling
invoiceBatch() / creditBatch()Desk.java:226-255EV-026, EV-027 / source_configNo outer transaction; partial failure leaves mixed stateNo test confirms rollback behavior
/api/healthDeskController.java:23,30-31EV-018 / source_configEchoes full spring.datasource.url in response bodyNo authentication guard on health endpoint in inspected source
/api/jobs/* endpointsDeskController.java; desk.js:82-83EV-021 / source_configBatch triggers invocable from browser with no auth check in inspected sourceSpring Security configuration not found in inspected scope
monthly() / annual() reportsDesk.java:259, :285-286EV-022, EV-023 / source_configFORMATDATETIME — H2-specific SQL functionFails against PostgreSQL without rewrite
pom.xmlBASE/app/pom.xmlEV-024 / source_configSpring Boot 2.7.18, Java 8, H2 2.2.224, spring-boot-starter-test presentCVE scan not performed; Spring Boot 2.7.x is EOL
InvoiceRunJobV2InvoiceRunJobV2.java:3-7EV-014, EV-030 / source_config, unknownStub returning 0; comment 'Unfinished replacement'; not in job registryOwner and activation path unknown
LegacyInvoicePrinterLegacyInvoicePrinter.java:1-6EV-004 (scope context) / source_configNo reference in any inspected source, test or configuration fileFull classpath and reflection scan not performed; dead-code candidate
GST constantDesk.java:29, :248EV-032, EV-027 / source_configGST_RATE=0.09 declared at class level; '0.09' also appears inline in creditBatchTwo-location duplication creates rate-change risk
Test suiteDeskMoneyTest.javaEV-024 / source_configOne test: money rounding to 2 decimal placesNo integration, route, batch, job or discount-boundary tests
BaseApplicationBaseApplication.java:1EV-035 / inferenceEnableScheduling + WebMvcConfigurer active; standard Spring Boot JARpom.xml packaging type not confirmed from source text

Legacy code health and poor-practice findings

Finding IDCategoryObserved condition and business/operational impactOrdinal severity (1=critical … 5=informational) and rationaleEvidence IDs / classTreatment / decision
F-001SecuritySQL injection in customers() and invoices() via unparameterized LIKE — attacker can read/modify/delete all application data1 — critical; direct exploitation possible at API layerEV-007, EV-008 / source_configFix before any deployment; parameterize queries
F-002Security/api/health echoes full JDBC URL to any caller — leaks storage location and engine type2 — high; enables targeted DB file access if binding is changedEV-018 / source_configRemove or redact before deployment
F-003SecurityBatch job trigger endpoints (POST /api/jobs/*) unauthenticated in inspected source2 — high; any user can trigger financial batch runsEV-021 / source_configAdd Spring Security role guard; confirm coverage
F-004Error/data handlingPreview/save discount off-by-one at qty=100 — customer sees lower price on screen than booked2 — high; billing correctness defect with direct revenue impactEV-009, EV-019 / source_configAlign boundary; business owner decision required
F-005Error/data handlingBatch jobs lack outer transaction — partial failure leaves mixed invoice/credit state2 — high; silent partial state is hard to detect and repairEV-026, EV-027 / source_configAdd @Transactional scope to batch methods
F-016Obsolete dependencyH2 FORMATDATETIME in reporting queries — fails immediately against PostgreSQL2 — high; blocks any H2→PostgreSQL migrationEV-001, EV-022, EV-023 / source_configReplace with portable date function before DB migration
F-017Obsolete dependencySpring Boot 2.7.18 (EOL Nov 2023) + Java 8 source level — no upstream CVE patches2 — high; EOL dependency, CVE scan not performedEV-024, EV-035 / source_config, inferenceUpgrade to Spring Boot 3 / Java 21; run CVE scan
F-018TestabilityOne unit test (money rounding); no integration, route, batch or discount tests2 — high; no regression net for financial logic or migrationEV-024 / source_configBuild test suite before migration begins
F-021OperationsH2 file-mode database with no documented backup or DR policy — financial data loss risk unquantified2 — high; unmitigated data loss riskEV-001, EV-028 / source_config, unknownOwner decision on backup/DR policy required
F-006Error/data handling[held back]3 — medium; silent batch failures undetectable from logsEV-015, EV-016 / source_configAdd structured logging and exception handling in wrappers
F-008ArchitectureCredit requests auto-approved with no workflow — no manager review or dollar-limit check3 — medium; bypasses financial controlsEV-034 / source_configBusiness owner decision on approval workflow required
F-009Dead-code candidateInvoiceRunJobV2 is an unregistered stub ('Unfinished replacement') — owner unknown3 — medium; ambiguity about intended implementation increases migration riskEV-004, EV-014, EV-030 / source_config, unknownOwner disposition required before migration
F-012ArchitectureCoarse synchronized methods serialize all writes — scalability blocker for horizontal expansion3 — medium; safe now; breaks under multi-node deploymentEV-011, EV-012 / source_configReplace COALESCE ID generation with DB sequences; remove JVM lock
F-013ArchitectureCOALESCE MAX+1 ID generation without DB sequence — duplicate key risk under any multi-node scenario3 — medium; migration blocker for horizontal or microservice extractionEV-012, EV-013 / source_configIntroduce DB sequences before any scaling or DB migration
F-014ArchitectureInline DDL at @PostConstruct, no migration framework — no column evolution or rollback path3 — medium; architectural blocker for CI/CD-driven schema managementEV-005, EV-006 / source_configIntroduce Flyway or Liquibase
F-015ArchitectureReflective job dispatch via deprecated Class.newInstance() — no compile-time binding3 — medium; runtime crash risk on config error; deprecated APIEV-004, EV-010, EV-025 / source_configReplace with Spring bean registry or compile-time dispatch
F-019DocumentationNo API spec, no business rule documentation, no operational runbook3 — medium; migration engineers must reverse-engineer all behavior from sourceEV-024, EV-029 / source_config, unknownOwner action: document rules, create runbook before handoff
F-020OperationsHardcoded demo date, disabled schedules, loopback binding — not production-ready defaults3 — medium; wrong results and inaccessibility in production deploymentEV-002, EV-003, EV-017, EV-029, EV-031 / source_config, unknownOwner confirmation of production config path required
F-022CouplingJob interface passes full Desk component — unrestricted access creates coupling risk3 — medium; latent risk for microservice extraction and isolation testingEV-025 / source_configIntroduce narrower JobContext interface
F-007Error/data handling50-line invoice limit enforced server-side only; no UI feedback4 — low; poor UX; no correctness impactEV-033 / source_configAdd client-side validation before client-facing deployment
F-010Dead-code candidateLegacyInvoicePrinter not referenced in any inspected path4 — low; not confirmed dead — full reflection scan not performedEV-004 (scope) / source_configPerform full call-site scan; remove if confirmed unused
F-011DuplicationGST rate 0.09 duplicated as class constant and inline literal in creditBatch()4 — low; split-rate bug on tax rate changeEV-027, EV-032 / source_configReplace inline literal with class constant
F-023CouplingBrowser SPA hardcodes /api base — same-origin deployment constraint undocumented4 — low; deployment blocker for CDN/API-gateway topologyEV-020 / source_configDocument constraint; add configurable base URL if needed

Not evidenced in supplied scope: No scan was performed for additional code smells beyond the inspected 13 Java source files. Frontend JavaScript is partially inspected (desk.js). No dynamic analysis, DAST, SAST tool run, dependency vulnerability scan or penetration test was performed. "Scan not performed" applies to all scanner-based security evidence categories.


Architecture, dependencies and operations

Dependency / call path / data flowFrom → toEvidence IDs / classOperational consequenceUnknown and validation owner
Browser SPA → Spring MVC APIdesk.js fetch('/api/*') → DeskControllerEV-020, EV-021 / source_configSame-origin deployment required; batch trigger endpoints exposed to browser usersNo network trace or access log; actual traffic patterns unknown (EV-029)
DeskController → DeskDirect method calls (calculate, save, runJob, requestCredit)EV-018, EV-019 / source_config[held back]No runtime call trace; relationship inferred from source
Desk → H2 file databaseJdbcTemplate SQL (DDL, DML, queries)EV-001, EV-006, EV-007, EV-008, EV-022, EV-023 / source_configSQL injection exposed; H2-specific functions block DB migration; no migration frameworkNo query execution plan or runtime SQL log
Spring Scheduling → ScheduledJobs → Desk.runJob()@Scheduled cron → ScheduledJobs.invoiceRun/creditRun → Desk.runJob()EV-002, EV-017, EV-031 / source_configCron triggers registered but execution suppressed by enabled flag; actual schedule activation in any environment unknownWhether schedules run in production is unknown (EV-031)
Desk.runJob() → Job implementations (reflection)Class.forName().newInstance() → InvoiceRunJob / CreditNoteJobEV-004, EV-005, EV-010, EV-015, EV-016 / source_configRuntime dispatch; misconfiguration causes startup or runtime crashInvoiceRunJobV2 status unknown (EV-030); reflection scan not performed
invoiceBatch() / creditBatch() → H2 (batch DML)Desk batch methods → JdbcTemplate multi-row DMLEV-026, EV-027, EV-012, EV-013 / source_configNo outer transaction; partial failure undetectable; COALESCE ID conflicts under scalingNo runtime execution log; batch behavior in production unknown
DeskController /api/health → spring.datasource.urlInjected property → JSON responseEV-018 / source_configInternal DB path exposed to any /api/health callerNo access log showing actual caller scope
Desk @PostConstruct → classpath resource jobs.propertiesStartup → Properties.load()EV-004, EV-005 / source_configMissing resource prevents application boot with no graceful fallbackClasspath confirmed in source; no test for missing-resource scenario

Security assessment

Finding IDDefect/advisory/scan typeComponent and affected-version stateExecution-path/reachability evidenceStatus and remediation proof
F-001Code defect — SQL injection (CWE-89)Desk.java customers(), invoices() — Spring Boot 2.7.18 / H2 2.2.224Reachable via GET /api/customers?q= and invoices search; no authentication guard in inspected sourceunder_investigation — confirmed defect from source; no runtime test or pentesting performed
F-002Configuration defect — credential exposure (CWE-200)DeskController.java /api/health — Spring Boot 2.7.18Reachable via GET /api/health; no authentication guard in inspected sourceunder_investigation — confirmed from source; endpoint caller scope unknown
F-003Configuration defect — missing authorization (CWE-862)DeskController.java /api/jobs/* — Spring Boot 2.7.18Reachable via POST /api/jobs/invoices and /api/jobs/credit-notes from browser buttonunder_investigation — no Spring Security config found in inspected scope; out-of-scope security filter cannot be confirmed or denied
F-008Process defect — missing access control on credit approvalrequestCredit() — Desk.java:199-201Reachable via HTTP POST from any authenticated userunder_investigation — auto-approve behavior confirmed from source; intended design not confirmed
F-017Dependency advisory — Spring Boot 2.7.18 EOL (CVE scan not performed)spring-boot-starter-parent 2.7.18; H2 2.2.224Spring Boot 2.7.x reached EOL November 2023; CVE scan not performed against pom.xmlunder_investigation — EOL fact established; specific CVE affectedness and execution path not investigated

Scan scope: No SAST, DAST, dependency vulnerability scanner (OWASP Dependency Check, Snyk, Trivy) or penetration test was performed. All security findings are derived from static source and configuration inspection. The absence of scanner results is not a clean score; it means "scan not performed."


Options and target tradeoffs

Three options are assessed for the overall estate. Target-specific detail is in target-annexes.md.

Option 1 — Retain and contain (Java 8 / Spring Boot 2.7.18 / H2): Remediate the security defects (F-001, F-002, F-003) and add a backup regime (F-021) without changing the platform. Lowest disruption and fastest delivery for the critical remediations. Does not resolve the EOL dependency risk (F-017), does not enable horizontal scaling (F-012), and defers the H2-to-production-DB gap (F-021 medium-term). Suitable as a short-term stabilisation step while the migration target is decided. Reversal evidence: CVE advisory on Spring Boot 2.7.x matching an exploitable production path would make this option untenable.

Option 2 — Bounded migration: Spring Boot 3 / Java 21 + H2 → PostgreSQL (recommended): Addresses F-017 (EOL upgrade), F-016 (H2-specific SQL must be rewritten), F-013 (introduce DB sequences), F-014 (introduce Flyway), and F-012 (remove JVM lock after sequences). Scope is confined to the existing Java architecture; no rewrite of business logic, no new language, no service decomposition. Estimated disruption: moderate (Jakarta EE namespace migration, test coverage build-up required for confidence). The separately authorized Java invoice-preview pilot validates the calculate() path on the target stack before committing to full migration. Reversal evidence: pilot failure on the calculate() path, or a confirmed business requirement for API decomposition that the retained monolith cannot satisfy.

Option 3 — Deeper modernisation (Kotlin / Microservice / FastAPI / ASP.NET Core): Justified by the coupling findings (F-012, F-022, F-023) and the desire to separate the invoice-preview service from batch processing. Higher delivery complexity and longer timeline. [a sentence held back] Reversal evidence: confirmed high concurrent load, security requirement for API isolation, or owner decision to separate operational concerns.

Unexecuted feasibility: H2-to-PostgreSQL schema compatibility, Spring Boot 3 migration test results, and Kotlin/FastAPI/ASP.NET Core compatibility are all unknown until executed. No feasibility checks have been run under the current execution policy.


Pilot actuals and future forecasts

MetricObserved pilot actual / future forecastValue / unknownSource run and revisionLimit
calculate() path: Java 21 / Spring Boot 3 compatibilityFuture forecast (pilot not yet authorized)Unknown[held back]Pilot scope: calculate() only; excludes save(), batch, jobs, DB migration
Test coverage increase (pilot)Future forecastUnknownPending pilotPilot adds unit tests for discount boundary and money rounding only
Build time (pilot)Future forecastUnknownPending pilotExcludes integration test suite
SQL injection remediation (F-001)Future forecastUnknown — requires parameterized query refactor and testPending remediation workScope: customers() and invoices() only
H2 → PostgreSQL migrationFuture forecastUnknownPending DB migration stage and authorized runFORMATDATETIME rewrite, schema migration, sequence introduction required

No pilot has been executed. All metrics above are forecasts. A successful build or preview does not prove end-to-end migration, performance, security, deployment or operational readiness.


Effort, dependencies and unresolved inputs

Effort figures are not priced in this assessment stage. The effort model (effort-model.csv) will be authored in the downstream commercial stage binding actual authorized rates, currency, and the validated finding set from this report. WBS items and role assignments will be detailed in implementation-plan.md.

Monetary fields remain not priced until an authorized rate card, currency, tax/expense treatment and conversion are documented. [a sentence held back]

Key effort dependencies observable from the findings: F-018 (test suite gap) must be resolved before any migration branch can provide regression confidence; F-001 and F-003 must be remediated before production exposure regardless of migration path; F-009 (InvoiceRunJobV2 disposition) and F-010 (LegacyInvoicePrinter scan) require owner decisions before the migration scope is fixed; F-021 (backup/DR) requires a client policy decision that will determine whether Option 1 is viable as an interim step.


Decisions, operational gaps and acceptance limits

Decision / gap IDOwner roleClient input / actionBlocksEvidence that closes it
DEC-001Client technical + security ownerSelect migration target profile (Option 1, 2 or 3)All WBS scoping, effort model, pilot authorizationWritten owner decision
DEC-002Business/product ownerConfirm discount boundary at qty=100: >=100 or >100? (F-004)Discount remediation, pilot test designWritten business rule decision
DEC-003Client operations + data ownerDefine backup/DR policy for H2 file database or confirm DB migration timeline (F-021)[held back]Written backup policy or confirmed migration decision
DEC-004Client technical ownerConfirm InvoiceRunJobV2 disposition: remove, complete, or replace? (F-009)Migration scope, job architecture refactoringOwner decision and resulting source change
DEC-005Client security ownerConfirm whether any out-of-scope security filter currently protects /api/jobs/* (F-003)Security finding severity and remediation scopeConfirmed security configuration evidence
DEC-006Business/product ownerConfirm whether auto-approve credit is intentional or a gap (F-008)Credit workflow design, authorization scopeWritten design decision
GAP-001Client operations ownerConfirm whether demo.schedule.enabled is ever set to true in any deployed environment (EV-031)F-020 severity and remediation priorityDeployment configuration inventory or environment scan
GAP-002Client security ownerCommission and return results of SAST / dependency vulnerability scan (F-017, F-001)CVE affectedness determination for Spring Boot 2.7.x and H2Scanner output with affected CVE list
GAP-003Client operations ownerProvide production data volume, backup retention and SLA requirements (EV-028, EV-029)Backup design, H2 vs PostgreSQL sizing decisionOperational inventory or owner interview

All seven pending decisions and gaps must be resolved before a production migration plan can be finalized. [a sentence held back]


Evidence disposition table

(One row per accepted evidence ID; disposition mapped means at least one final finding references this evidence ID.)

Evidence IDDispositionFinding IDsOwnerReason
EV-001mappedF-016, F-021nullnull
EV-002mappedF-020nullnull
EV-003mappedF-020nullnull
EV-004mappedF-009, F-010, F-015nullnull
EV-005mappedF-014nullnull
EV-006mappedF-014nullnull
EV-007mappedF-001nullnull
EV-008mappedF-001nullnull
EV-009mappedF-004nullnull
EV-010mappedF-015nullnull
EV-011mappedF-012nullnull
EV-012mappedF-012, F-013nullnull
EV-013mappedF-013nullnull
EV-014mappedF-009nullnull
EV-015mappedF-006nullnull
EV-016mappedF-006nullnull
EV-017mappedF-020nullnull
EV-018mappedF-002nullnull
EV-019mappedF-004nullnull
EV-020mappedF-023nullnull
EV-021mappedF-003nullnull
EV-022mappedF-016nullnull
EV-023mappedF-016nullnull
EV-024mappedF-017, F-018, F-019nullnull
EV-025mappedF-015, F-022nullnull
EV-026mappedF-005nullnull
EV-027mappedF-005, F-011nullnull
EV-028mappedF-021nullnull
EV-029mappedF-019, F-020nullnull
EV-030mappedF-009nullnull
EV-031mappedF-020nullnull
EV-032mappedF-011nullnull
EV-033mappedF-007nullnull
EV-034mappedF-008nullnull
EV-035mappedF-017nullnull

Target-specific assessment annexes

Each annex links consolidated findings from assessment.md. Assessment and planning only except the separately authorized Java invoice-preview pilot. No target implementation has been executed or inferred. For every annex the three standard subsections are: decision/options; compatibility/behavior/evidence; and delivery/rollback/open decisions.


Java 8 → Java 21

Profile: java-8-to-21

Decision and options

Assessment recommendation: proceed with Java 21 upgrade as the first mandatory step of the bounded migration path (Option 2 in `assessment.md`).

This upgrade is a prerequisite for Spring Boot 3 (which requires Java 17 minimum; Java 21 is the current LTS target). The Java 8 source level (EV-024) is the binding constraint on all modern library adoption. Three options exist for the Java upgrade alone:

  • Option A — In-place Java 21 upgrade (recommended): Upgrade java.version to 21, update Spring Boot to 3.x (which carries the Jakarta EE namespace migration), and recompile without any business logic changes. Combine with F-015 remediation (Class.newInstance() → getDeclaredConstructor().newInstance()) and F-017 EOL resolution in the same branch.
  • Option B — Java 17 intermediate step: Upgrade to Java 17 first to validate build compatibility before the Spring Boot 3 Jakarta namespace migration. Adds a delivery increment but reduces the blast radius of any single upgrade step.
  • Option C — Retain Java 8 (not recommended): Resolves no EOL risk; blocks all other migration targets that require Java 17+. Only viable as a short-term stabilisation hold while Option 1 security remediations are applied.

Decision trigger: F-017 (EOL), F-015 (deprecated API), F-018 (test coverage required before migration confidence). F-009 (InvoiceRunJobV2 disposition) must be resolved to confirm that the upgraded build contains no dead stub that masks a missing replacement.

Compatibility, behavior and evidence

Impacted BASE components: All Java source files in com.ingramtrials.base — Desk.java (19 KB), DeskController.java, ApiErrors.java, FallbackError.java, ScheduledJobs.java, InvoiceRunJob.java, CreditNoteJob.java, InvoiceRunJobV2.java, LegacyInvoicePrinter.java, Job.java, BaseApplication.java.

Runtime and build invariants:

InvariantEvidenceResult / status
Class.newInstance() deprecated since Java 9EV-010 / source_configMust replace before Java 21 compiler warnings become errors in strict mode
javax.servlet.* imports (FallbackError.java, ApiErrors.java)Source inspection / source_configSpring Boot 3 migrates to jakarta.servlet.*; these imports will fail to compile on Spring Boot 3 without change
@EnableScheduling, WebMvcConfigurer (BaseApplication.java)EV-035 / inferenceBoth are present in Spring Boot 3 with compatible APIs; no breaking change expected
H2 2.2.224 compatibility with Java 21EV-024 / source_configH2 2.2.x is tested with Java 21; no known incompatibility, but compatibility test not run
synchronized keyword compatibilityEV-011, EV-012 / source_configNo change; synchronized remains valid in Java 21. Virtual threads (Project Loom) interact with synchronized differently — pinning risk if virtual threads are adopted; this assessment does not adopt virtual threads
BigDecimal arithmetic behaviorEV-009, EV-011 / source_configNo behavioral change in BigDecimal arithmetic between Java 8 and 21 for the rounding modes used

Feasibility checks: Not run. Build compilation against Java 21 and Spring Boot 3 not executed under the current execution policy. Results are unknown.

Scoped security status: F-017 under_investigation — EOL fact established; specific CVE affectedness requires a dependency scanner run which has not been performed (EV-024).

Delivery, rollback and open decisions

WBS/effort basis: Effort is not priced. Accepted plan tasks: WBS-3.1 (Jakarta namespace migration, Class.newInstance() replacement, Spring Boot 3 / Java 21 build), WBS-1.3 (baseline test suite required before migration confidence). Key tasks include: Jakarta namespace migration (javax → jakarta across all source files), Class.newInstance() replacement (F-015), Spring Boot 3 dependency resolution and test run, compilation verification, and execution of the DeskMoneyTest regression baseline. The test suite gap (F-018) is a parallel prerequisite — migration confidence requires more than one test.

Sequencing/dependencies: Java 21 upgrade must precede the H2 → PostgreSQL migration (the PostgreSQL driver for Spring Boot 3 requires JDBC 4.3+, included in the Java 21 / SB3 stack). The pilot (calculate() path) can be executed in parallel with the Java upgrade once the build compiles.

Rollback boundary: Revert the java.version property and the Spring Boot parent version in pom.xml. Keep the original source under version control; the Jakarta namespace change is reversible by reverting the branch.

Uncertainty: The Jakarta EE namespace migration surface area is limited to the servlet imports in FallbackError.java and ApiErrors.java, plus any transitive Spring dependencies. The actual impact will not be known until a compile run is executed.

Client input required: DEC-001 (migration target decision), F-009 (InvoiceRunJobV2 disposition before final source inventory is locked).

Pending decision owner: Delivery tech lead.


Java → Kotlin

Profile: java-to-kotlin

Decision and options

Assessment recommendation: not recommended as the primary migration path at this time. Kotlin migration is viable as a long-term improvement objective after bounded migration (Java 21 + Spring Boot 3) stabilises the estate.

Kotlin interop migration is justified when a team wants null-safety enforcement, coroutines, or idiomatic functional patterns, and the existing test coverage is sufficient to validate behavioral equivalence. [a sentence held back] The low test coverage makes a Kotlin rewrite high risk for behavioral regression.

  • Option A — Kotlin new-code policy: New files added during migration are written in Kotlin; existing Java source is retained. Low risk; slow coverage gain. Viable after Java 21 upgrade.
  • Option B — Incremental Kotlin conversion: Convert one component at a time (Job interface and wrappers first, then DeskController, then Desk). Requires Java–Kotlin interop validation at each boundary.
  • Option C — Full Kotlin rewrite: Maximum Kotlin coverage; highest delivery risk given current test baseline. Not recommended without a comprehensive test suite in place first (F-018).

Decision trigger: F-018 (test suite must be built before a rewrite is safe), DEC-001 (target selection).

Compatibility, behavior and evidence

Impacted BASE components: All Java source files would be converted. Key interop invariants:

InvariantEvidenceResult / status
synchronized keywordEV-011, EV-012 / source_configKotlin uses @Synchronized annotation; behavioral equivalence in single-JVM; same virtual-thread caution applies
BigDecimal arithmetic (money rounding, GST, discount)EV-009, EV-032 / source_configKotlin delegates to Java BigDecimal; no behavioral change expected but must be confirmed by test
Reflective job dispatch (Class.forName)EV-010 / source_configKotlin supports reflection via KClass and Java Class; existing reflection pattern is replaceable with Kotlin idioms
@PostConstruct, @Scheduled, @ControllerAdviceEV-005, EV-017, source inspectionSpring annotations work identically in Kotlin; no compatibility barrier
Null safety for String SQL parameters (EV-007, EV-008)EV-007, EV-008 / source_configKotlin null-safety would surface the SQL injection defect as a platform concern but does not fix it; F-001 remediation is independent
Decimal serialization in REST responsesEV-019 / source_configJackson BigDecimal serialization behavior is unchanged; must be confirmed by test

Feasibility checks: Not run. Kotlin compilation and interop tests not executed.

Delivery, rollback and open decisions

WBS/effort basis: Effort is not priced. Accepted plan tasks: WBS-1.3 (baseline test suite — required before Kotlin conversion is safe), WBS-3.10 (tech lead integration review, Wave 3 — covers all converted components). Kotlin migration effort is higher than Java 21 in-place upgrade due to the syntactic rewrite scope. Baseline test suite (F-018) must be authored before migration begins.

Increment boundary: Job interface and wrappers are the smallest safe first increment (minimal logic, clear interface boundary). Desk.java is the highest-risk last increment due to its financial logic density.

Rollback: Kotlin files can coexist with Java on the same classpath; reverting a Kotlin file to its Java equivalent is always possible while the original is retained under version control.

Client input required: DEC-001 (target selection), F-018 (test suite build before conversion).

Pending decision owner: Client technical owner and delivery tech lead.


JavaScript → TypeScript

Profile: javascript-to-typescript

Decision and options

Assessment recommendation: TypeScript migration for desk.js is a low-risk quality improvement, recommended as a parallel track to the backend migration rather than a blocker.

The browser SPA consists of one JavaScript file (desk.js, 6 190 bytes) with no build toolchain, no module system and all API calls using the Fetch API with hardcoded paths (EV-020). TypeScript adoption would add type-checked API contract definitions and surface the hardcoded /api base path as an explicit configuration concern (F-023).

  • Option A — TypeScript with a build step (recommended): Introduce a minimal tsconfig.json and compile desk.ts to desk.js. No runtime change; browser behavior is identical. Type definitions for API request/response shapes would surface the discount boundary (F-004) and line-limit (F-007) constraints at the IDE level.
  • Option B — Retain JavaScript (acceptable for short term): No new risk introduced; F-023 and F-007 remain unaddressed at the UI layer.
  • Option C — Frontend framework adoption (out of scope): Introducing React/Vue/Angular is a larger scope change not implied by the current estate and not recommended without a client UI roadmap decision.

Decision trigger: F-023 (hardcoded base URL), F-007 (no client-side line-limit validation), DEC-001.

Compatibility, behavior and evidence

Impacted frontend modules: desk.js only. index.html (static markup, 4 885 bytes) and style.css (4 998 bytes) are unaffected.

InvariantEvidenceResult / status
Browser API compatibilityEV-020 / source_configFetch API and standard DOM APIs are TypeScript-compatible without polyfills
API base path constraintEV-020 / source_configF-023: same-origin constraint must be documented or resolved before TS migration introduces a typed API_BASE_URL
Hardcoded paths in fetch callsEV-020, EV-021 / source_configAll 10+ fetch calls in desk.js use '/api' prefix; TypeScript migration is the opportunity to introduce an API_BASE constant
No module systemEV-020 / source_configdesk.js uses no import/export; TypeScript migration requires a module bundler (esbuild, Vite) or inline script pattern
Spring Boot static resource servingEV-003, EV-035 / source_config, inferenceSpring Boot serves static files from src/main/resources/static/; compiled desk.js must replace or augment the original

Feasibility checks: Not run. TypeScript compilation and build toolchain not configured or tested.

Delivery, rollback and open decisions

WBS/effort basis: Effort is not priced. Accepted plan tasks: WBS-3.5 (remove JVM lock, introduce JobContext, resolve /api base path constraint — F-023), WBS-4.2 (client-side line-count validation in desk.js — F-007). Scope: rename desk.js → desk.ts, add tsconfig.json, add a build step to pom.xml or a separate npm build, type the API shapes, resolve F-023 (configurable base URL). Small scope; low delivery risk.

Module order: Migrate desk.js as a single unit; no incremental file-by-file order is needed.

Build rollback: Retain the original desk.js alongside the TypeScript source. If the build step fails, Spring Boot can serve the pre-compiled JavaScript file unchanged.

Client input required: DEC-001, deployment topology decision (required to resolve F-023).

Pending decision owner: Delivery tech lead (frontend competency confirmation required).


H2 → PostgreSQL

Profile: h2-to-postgresql

Decision and options

Assessment recommendation: H2 → PostgreSQL migration is required for any production deployment. It is the highest-impact structural change in the bounded migration path and must be sequenced after the Java 21 / Spring Boot 3 upgrade.

H2 is a development and demo database. The financial data (invoices, orders, credit notes) requires a production-grade RDBMS with WAL backup, point-in-time recovery, and concurrent-access semantics. Three database-layer options exist:

  • Option A — PostgreSQL (recommended): Strongest open-source production support, native JSON, SERIAL/SEQUENCE identity, TO_CHAR() replaces FORMATDATETIME (F-016), schema migration via Flyway aligns with F-014 remediation.
  • Option B — MySQL/MariaDB: Viable alternative if the client has an existing MySQL estate; requires STR_TO_DATE() replacement for FORMATDATETIME and separate sequence strategy.
  • Option C — Retain H2 with backup regime (short-term only): Documented in assessment.md as a stabilisation step; does not resolve F-016 (H2-specific SQL), F-013 (sequence-based IDs), or F-021 (backup/DR gap) long-term.

Decision trigger: F-016 (H2 FORMATDATETIME blocker), F-013 (COALESCE ID pattern), F-014 (inline DDL — must be migrated to Flyway scripts), F-021 (backup/DR policy), F-012 (synchronized lock removal depends on DB sequences).

Compatibility, behavior and evidence

Impacted components: Desk.java (all JdbcTemplate SQL), application.properties (datasource config), @PostConstruct DDL (EV-006), all reporting queries (EV-022, EV-023), ID generation (EV-013, EV-012).

InvariantEvidenceResult / status
FORMATDATETIME → TO_CHAREV-022, EV-023 / source_configFour confirmed uses in monthly() and annual(); must be replaced before cutover — not run
COALESCE MAX+1 → SERIAL/SEQUENCEEV-012, EV-013 / source_configInvoice ID and credit_request ID generation must be replaced with PostgreSQL SERIAL or SEQUENCE columns; schema migration script required
CREATE TABLE IF NOT EXISTS DDLEV-006 / source_configAll eight DDL statements must be extracted into Flyway V1__init.sql migration; @PostConstruct setup() retains only application-level initialization
DB_CLOSE_ON_EXIT=FALSE H2 propertyEV-001 / source_configRemove H2-specific JDBC URL properties; replace with PostgreSQL JDBC URL and connection pool config
H2 in-memory → file test behaviorEV-001, EV-024 / source_configIntegration tests currently have no H2 in-memory test config; PostgreSQL migration introduces a test-container dependency for integration tests
SQL dialect (string functions, date types)EV-022, EV-023 / source_configNo other H2-specific functions confirmed in inspected scope beyond FORMATDATETIME; additional dialect differences possible in uninspected query paths
Invoice reconciliation invariantEV-009, EV-026, EV-027 / source_configThe discount off-by-one (F-004) and batch atomicity gap (F-005) must be remediated before or concurrent with the DB migration to avoid persisting incorrect data to PostgreSQL

Feasibility checks: Not run. PostgreSQL dialect test, Flyway migration script execution, and connection pool configuration not tested.

Delivery, rollback and open decisions

WBS/effort basis: Effort is not priced. Accepted plan tasks: WBS-3.2 (Flyway DDL extraction — F-014), WBS-3.3 (FORMATDATETIME → TO_CHAR replacement and COALESCE → SERIAL/SEQUENCE migration — F-013, F-016). Key tasks: Flyway DDL extraction (F-014), FORMATDATETIME replacement in all queries (F-016), SERIAL/SEQUENCE ID migration (F-013), application.properties datasource reconfiguration, PostgreSQL connection pool (HikariCP, default in Spring Boot 3), integration test update with Testcontainers, dual-run verification.

[a sentence held back] No data migration tooling is present in the current source; client data volume (EV-028, EV-029) is unknown and must be confirmed before sizing the cutover window.

Backup/restore rollback: H2 file backup must be captured before cutover. PostgreSQL rollback requires a point-in-time recovery snapshot; this depends on the DEC-003 backup policy decision.

Volume/environment gaps: GAP-003 (data volume and retention requirements) and DEC-003 (backup/DR policy) are unresolved. These must be closed before the DB migration can be scoped.

Client input required: DEC-003 (backup/DR policy), GAP-003 (data volume), DEC-001 (migration target confirmation).

Pending decision owner: Client operations and data owner; delivery tech lead.


Monolith → Preview microservice

Profile: monolith-to-preview-service

Decision and options

Assessment recommendation: microservice extraction of the invoice-preview path is a viable future option, not a recommended immediate action. The bounded migration (Option 2) should stabilise the estate before decomposition is attempted.

The only clean extraction boundary evidenced in the current source is Desk.calculate() (the preview path), which has no database write, no job dependency and no synchronized lock requirement. This is the scope of the separately authorized Java invoice-preview pilot.

  • Option A — Extract preview service (aligned with pilot scope): Separate Desk.calculate() into a stateless preview microservice. The pilot validates this boundary. The remaining monolith retains save(), batch, credit, reporting and job dispatch.
  • Option B — Retain monolith, expose preview as an internal module: Keep the application architecture unchanged but isolate the calculate() path behind a module boundary. Lower deployment complexity; no service boundary crossing.
  • Option C — Full decomposition (not recommended at this stage): Decompose into invoice-service, batch-service, credit-service and reporting-service. Requires resolving F-013 (shared ID generation), F-022 (Job interface coupling), F-012 (synchronized lock), F-020 (schedule ownership), and F-021 (data ownership per service). High delivery complexity for an estate of this size.

Decision trigger: F-012 (synchronized lock blocks scaling), F-013 (ID generation must be resolved), F-022 (Job coupling), DEC-001, pilot completion.

Compatibility, behavior and evidence

Boundary/call graph evidence:

BoundaryEvidenceStatus
calculate() has no DB writeEV-009, EV-019 / source_configConfirmed from source — calculate() returns a result map without persisting
calculate() uses synchronized lock?EV-011 / source_configNo — save() is synchronized, not calculate(). Preview path is stateless and lock-free
API contract: POST /api/invoices/previewEV-019 / source_configRoute maps to Desk.calculate(body, true); request/response shape is JSON; no external contract (F-019)
Authentication on preview endpointEV-021, EV-003 / source_configNo auth guard visible in inspected source; same concern as F-003
IdempotencyEV-019 / source_configPreview is read-only and stateless; fully idempotent
Data ownershipEV-001 / source_configPreview service needs product and customer data (read-only); currently co-located in H2; shared DB or read-replica required if decomposed

Feasibility checks: Not run. Service boundary test, inter-service contract validation and observability not configured.

Delivery, rollback and open decisions

WBS/effort basis: Effort is not priced. Accepted plan tasks: WBS-2.1 (pilot — extract Desk.calculate() to Java 21 / Spring Boot 3 preview endpoint, auth guard, discount-boundary tests), WBS-3.5 (remove JVM synchronized lock and introduce JobContext interface — F-012, F-022, prerequisite for full decomposition). Key tasks for pilot scope: extract calculate() to new Spring Boot module, expose POST /api/invoices/preview, add discount-boundary tests (F-004 fix), add authentication guard (F-003), validate against Java 21 / Spring Boot 3. Full decomposition adds: API gateway configuration, service-to-service auth, shared data access pattern, distributed transaction strategy (not needed for preview-only service).

Strangler/route sequence: Pilot extracts calculate() first. Other routes remain in the monolith. Route rollback is a Spring Boot proxy/redirect from the monolith to the preview service, reversible by removing the redirect.

Route rollback: Restore original /api/invoices/preview route in the monolith if the preview service fails health checks. No data migration is involved.

Operational ownership gaps: Service discovery, health check monitoring, container orchestration and deployment pipeline for the preview service are not configured in the current source. These are client operational decisions.

Client input required: DEC-001, DEC-005 (auth coverage confirmation), pilot authorization.

Pending decision owner: Client architecture owner and delivery tech lead.


Java → Python / FastAPI

Profile: java-to-fastapi

Decision and options

Assessment recommendation: FastAPI rewrite is not recommended as the primary migration path for this estate. It is viable for a greenfield preview microservice if the client's platform preference or team expertise strongly favors Python, but it requires a full rewrite of all business logic with no automated translation path.

The current estate is tightly coupled in Desk.java (financial math, SQL, job dispatch, scheduling, seed data management in a single class). A FastAPI rewrite would need to faithfully reproduce every discount boundary, GST calculation and invoice-line constraint — a high behavioral risk given the current test coverage (F-018, one test).

  • Option A — FastAPI for preview service only (limited scope): Implement POST /api/invoices/preview in FastAPI with Pydantic schemas. Scope aligns with the pilot boundary. Requires confirming the Decimal/BigDecimal rounding equivalence.
  • Option B — Full FastAPI rewrite: All endpoints, batch jobs, reporting and scheduling reimplemented in Python. High delivery risk; no behavioral test baseline.
  • Option C — Retain Java (recommended for full estate): FastAPI adds Python operating-model overhead (deployment, async patterns, Pydantic v2) without evidence that the performance or productivity gain justifies the risk for this size of application.

Decision trigger: F-018 (test suite required before any rewrite), DEC-001, client platform preference.

Compatibility, behavior and evidence

Impacted components: All endpoints and job paths.

InvariantEvidenceStatus
BigDecimal → Python Decimal equivalenceEV-009, EV-032 / source_configJava BigDecimal with HALF_UP rounding (Desk.money()) must map to Python Decimal with equivalent rounding; behavior test required
GST 9% calculation and scaleEV-027, EV-032 / source_configPython Decimal division behavior differs subtly from BigDecimal; must be confirmed by test
Discount boundary at qty=100/>=100/>100EV-009 / source_config[held back]
Async batch jobs (invoiceBatch, creditBatch)EV-026, EV-027 / source_configPython async or background task patterns differ from Spring @Scheduled; transaction semantics must be reproduced
SQL → SQLAlchemy / asyncpgEV-007, EV-008, EV-022, EV-023 / source_configF-001 SQL injection must not be reproduced; parameterized queries are default in SQLAlchemy ORM
Pydantic noncoercing schemasEV-033, EV-009 / source_config[held back]

Feasibility checks: Not run. FastAPI compilation, Decimal behavior and async batch patterns not tested.

Delivery, rollback and open decisions

WBS/effort basis: Effort is not priced. Accepted plan tasks: WBS-1.3 (baseline test suite — required before any rewrite to validate behavioral equivalence), WBS-3.11 (full regression run on Java 21 / Spring Boot 3 / PostgreSQL — establishes the passing baseline that a FastAPI rewrite must match). Full rewrite scope is larger than bounded Java migration. Python operating-model (virtual environment, dependency management, container image, async framework) adds operational overhead not present in the current Spring Boot JAR.

Route/data sequence: Preview endpoint first (lowest state risk); batch/job/reporting endpoints require async pattern design; database migration is the same prerequisite as Option 2 regardless of language.

Rollback: Java and FastAPI can coexist behind a proxy during transition; full rollback requires maintaining the Spring Boot application in parallel.

Python operating-model gaps: No Python deployment configuration, CI/CD pipeline or container image exists in the current source. These require client operational investment.

Client input required: DEC-001, client platform preference, F-018 (test suite before rewrite).

Pending decision owner: Client technical owner and delivery tech lead.


Java → C# / ASP.NET Core

Profile: java-to-aspnet-core

Decision and options

Assessment recommendation: ASP.NET Core rewrite is not recommended as the primary migration path for this estate. It is viable if the client's enterprise standard platform is .NET, but it requires a full rewrite with the same behavioral risk profile as the FastAPI option given the current test coverage (F-018).

[a sentence held back] However, the BigDecimal financial math, the SQL injection remediation scope and the H2-to-PostgreSQL migration are independent concerns that apply regardless of the target language.

  • Option A — ASP.NET Core Web API for preview service only: Implement POST /api/invoices/preview in ASP.NET Core. Aligns with pilot boundary. Requires Decimal/JSON binding validation.
  • Option B — Full ASP.NET Core rewrite: All endpoints, IHostedService for background jobs, EF Core or Dapper for database access. High delivery risk without a test baseline.
  • Option C — Retain Java (recommended for full estate): Same rationale as FastAPI option; .NET operating-model adds platform overhead not justified by the current estate complexity.

Decision trigger: F-018 (test suite required before rewrite), DEC-001, client platform standard.

Compatibility, behavior and evidence

Impacted components: All endpoints and job paths.

InvariantEvidenceStatus
decimal/JSON binding (C# decimal vs Java BigDecimal)EV-009, EV-032 / source_configC# decimal has 28-29 significant digits vs Java BigDecimal arbitrary precision; rounding behavior must be confirmed for HALF_UP equivalent (MidpointRounding.AwayFromZero)
Model binding overflowEV-033 / source_configASP.NET Core model binding with strict decimal validation required to reproduce the 1-50 line limit and scale checks (F-007)
Middleware exception handlingSource inspection (ApiErrors.java, FallbackError.java) / source_configSpring @ControllerAdvice maps to ASP.NET Core Exception Middleware or ProblemDetails; functional equivalence must be tested
Background services (IHostedService) for schedulingEV-017, EV-002 / source_configSpring @Scheduled maps to IHostedService with a timer; demo.schedule.enabled guard must be reproduced
EF Core / Dapper for SQLEV-007, EV-008, EV-022, EV-023 / source_configF-001 SQL injection must not be reproduced; parameterized queries are default in EF Core; FORMATDATETIME must be replaced with EF LINQ date functions
Static file serving for SPAEV-020, EV-003 / source_configASP.NET Core UseStaticFiles() serves desk.js; same-origin constraint (F-023) applies unchanged
Hosting model (.NET vs embedded Tomcat)EV-035 / inferenceSpring Boot embedded Tomcat maps to Kestrel in ASP.NET Core; Kestrel binding and port configuration replaces server.address/server.port

Feasibility checks: Not run. ASP.NET Core compilation, Decimal rounding and middleware tests not executed.

Delivery, rollback and open decisions

WBS/effort basis: Effort is not priced. Accepted plan tasks: WBS-1.3 (baseline test suite — required before any rewrite to validate behavioral equivalence), WBS-3.11 (full regression run on Java 21 / Spring Boot 3 / PostgreSQL — establishes the passing baseline that an ASP.NET Core rewrite must match). Full rewrite scope similar to FastAPI option. .NET operating-model (SDK, runtime, container image, IIS/Kestrel deployment) adds operational overhead not present in the Spring Boot JAR.

Hidden here: a sentence our checks hold back.

Rollback: Java and ASP.NET Core can coexist behind a reverse proxy; full rollback requires maintaining the Spring Boot application in parallel.

.NET operating-model gaps: No .NET project file, SDK configuration or container image exists in the current source. Client .NET platform investment is required.

Client input required: DEC-001, client platform standard decision, F-018 (test suite before rewrite).

Pending decision owner: Client technical owner and delivery tech lead.

Back to the four documents

Bob's document · written by IBM Bob

Effort model

Every work package in hours, as a low, central and high range, with what could change it.

Migration assessmentEffort modelImplementation planStatement of work

Bob wrote this inside our test setup. Our automatic checks hold back 3 table cells, each place marked: most mention that setup, a few only share a word with it. Every other word is Bob's, unchanged.

DeliverableWork packageTaskRoleLowCentralHighWhat could change it
DEL-001WBS-1.1SQL injection remediation — parameterize customers() and invoices() queriesSI developer152130F-001 remediation scope may widen if additional query builders are found in non-inspected paths (noted in findings); no runtime test performed; client source access confirmation required
DEL-002WBS-1.2Security endpoint hardening — redact /api/health and add auth guard on /api/jobs/**SI developer101420DEC-005 unresolved — scope of Spring Security work depends on whether any out-of-scope filter already protects /api/jobs/**; if absent, configuration scope increases
DEL-003WBS-1.3Test suite foundation — integration tests for routes, batch atomicity, discount boundary and job dispatchSI developer202840Current test coverage is a single unit test; no integration test environment is configured; test-container or embedded-H2 configuration scope is unknown until attempted
DEL-004WBS-1.4Wave 1 independent review — security remediations and test suiteSI senior reviewer81216Review duration depends on code quality and number of review cycles; reviewer independence requirement reduces throughput vs. author self-review
DEL-005WBS-1.5[held back]SI PM81624[held back]
DEL-006WBS-2.1Java invoice-preview pilot — Desk.calculate() on Java 21 / Spring Boot 3 with auth guard and discount testsSI developer202840Pilot not yet authorized; Jakarta EE namespace migration surface unknown until compile run; discount boundary DEC-002 required for test design; rollback route complexity unknown
DEL-007WBS-2.2Java invoice-preview pilot — independent reviewSI senior reviewer468Pilot authorization pending; review scope depends on WBS-2.1 output quality
DEL-008WBS-2.3Java invoice-preview pilot — QA verification and pilot-evidence.jsonSI QA468Pilot authorization pending; test environment availability unknown; human acceptance still pending after pilot-evidence.json is produced
DEL-009WBS-3.1Java 21 / Spring Boot 3 upgrade — Jakarta namespace migration and deprecated API remediationSI developer253550Jakarta EE namespace migration surface unknown until compile run; transitive Spring dependencies may reveal additional incompatibilities; DEC-004 InvoiceRunJobV2 disposition must be resolved before scope is locked
DEL-010WBS-3.2Schema migration framework — extract inline DDL to Flyway V1__init.sqlSI developer152130Seed data / DDL ordering may require iteration to establish correct Flyway migration order; no migration framework is currently present so no baseline exists
DEL-011WBS-3.3PostgreSQL dialect and sequences — SERIAL/SEQUENCE ID migration and FORMATDATETIME replacementSI developer202840Additional H2-specific SQL functions may exist beyond confirmed FORMATDATETIME sites; data volume (GAP-003) unknown; PostgreSQL environment availability for Testcontainers depends on CI configuration; DEC-003 backup/DR policy required
DEL-012WBS-3.4Batch atomicity and job error handling — @Transactional on batch methods and structured job loggingSI developer152130Idempotency guard scope — if the client requires re-runnable batches (zero-duplication guarantee), scope increases; PostgreSQL transaction semantics differ slightly from H2 and must be confirmed by integration test
DEL-013WBS-3.5Architecture decoupling — remove JVM lock, introduce JobContext interface, resolve /api base pathSI developer152130Client topology decision pending (DEC-001 scope); if CDN/cross-origin deployment is required (F-023), CORS configuration adds scope; JobContext refactoring scope depends on Spring bean injection pattern chosen
DEL-014WBS-3.6Dead-code disposition — InvoiceRunJobV2 and LegacyInvoicePrinter per DEC-004SI developer101420DEC-004 owner decision is the primary driver — removal vs. completion changes scope significantly; LegacyInvoicePrinter full call-site scan result required before removal
DEL-015WBS-3.7Production readiness and backup — remove demo date, add prod config profile, implement backup per DEC-003SI developer152130DEC-003 backup policy scope unknown — H2 file-copy is simpler than PostgreSQL WAL configuration; GAP-001 (schedule.enabled confirmation) may reveal additional environment-specific config requirements; data volume GAP-003 affects backup sizing
DEL-016WBS-3.8Discount boundary alignment and GST constant deduplicationSI developer101420[held back]
DEL-017WBS-3.9API and business rule documentation — OpenAPI spec, business rules document, operations runbookSI tech lead121824All eight decisions (DEC-001 to DEC-006, GAP-001 to GAP-003) must be closed before documentation can accurately describe the target system behavior
DEL-018WBS-3.10Wave 3 tech lead integration reviewSI tech lead121824Review complexity depends on Wave 3 implementation quality; reviewer must be independent of all Wave 3 development authors
DEL-019WBS-3.11Full regression — Java 21 / Spring Boot 3 / PostgreSQL stack validationSI QA121824Testcontainers PostgreSQL environment availability; client confirmation that test seed data is representative; any regression from WBS-3.1 to WBS-3.9 discovered during regression run requires repair loop
DEL-020WBS-4.1Credit approval workflow — implement per DEC-006 (PENDING state or auto-approve sign-off)SI developer152130DEC-006 decision is the dominant driver — auto-approve confirmation requires documentation only (reduces scope substantially); PENDING+workflow requires new API, role configuration and integration test
DEL-021WBS-4.2Client-side 50-line limit validation in desk.jsSI developer5710desk.js has no build toolchain — if a TypeScript migration (scope exclusion) is separately authorized, this task would be absorbed; otherwise plain JS addition is the approach
DEL-022WBS-4.3Final PM delivery review — traceability reconciliation and handoffSI PM101420PM traceability scope depends on completeness of all prior wave outputs; any unresolved finding or open decision extends this task
GRAND TOTAL ALL INCLUDED252362512
EXCLUDED TOTAL 2.1 2.2 2.3284056

The file also gives each row its method, evidence and status; the table shows the columns an SI prices from.

Back to the four documents

Bob's document · written by IBM Bob

Implementation plan

The waves, the work packages in order, who does them and what must be true before each starts.

Migration assessmentEffort modelImplementation planStatement of work

Bob wrote this inside our test setup. Our automatic checks hold back 18 sentences and 16 table cells, each place marked: most mention that setup, a few only share a word with it. The file's metadata block is left out. Every other word is Bob's, unchanged.

Scope and source of effort

[a sentence held back] Target annexes from target-annexes.md (same run). Effort-model.csv will be authored in the downstream commercial stage (si-business) binding actual authorized rates and the validated finding set from this report. Monetary fields remain not priced here.

Recommended migration path: Option 2 — Bounded migration: Spring Boot 3 / Java 21 + H2 → PostgreSQL (per assessment.md recommendation). The plan is structured for this path. Other options (retain-and-contain, deeper modernisation, FastAPI, ASP.NET Core, Kotlin rewrite) remain planning-only and are forecast exclusions from the current WBS.

Pilot boundary: The Java invoice-preview pilot (Wave 2, WBS-2.1 through WBS-2.3) is proposed and separately gated. It covers Desk.calculate() only (preview, no save or batch), targeting Java 21 / Spring Boot 3. [a sentence held back]

Pilot exclusions from enterprise scope: Database migration, batch job paths, authentication system, browser SPA rebuild, deployment to production environments, Kotlin/FastAPI/ASP.NET Core rewrites, full decomposition into microservices, performance/load testing, DAST/SAST scan execution (gap GAP-002 remains open), and any target not in Option 2.

[a sentence held back] F-001 (SQL injection), F-002 (credential exposure) and F-003 (unauthenticated batch triggers) must be remediated and independently verified before any production exposure. [a sentence held back]

Effort totals: Not priced. Duration figures are elapsed working-day assumptions (relative/illustrative) computed from predecessor dependencies; they assume one resource per task, sequential execution within a wave, and unknown client calendars. Label: relative_illustrative until capacity and calendar evidence exist.


Delivery waves and work packages

Wave 1 resolves critical security findings and builds the test foundation. Wave 2 is the proposed bounded pilot (separately gated). Wave 3 executes the bounded migration. Wave 4 closes owner-decision-dependent findings and performs the final handoff. [a sentence held back]

WBS ID / deliverableFinding IDsOwner rolePredecessorsClient inputEffort low / central / high (hours)Relative duration / basisExit evidence / milestoneAssumption / status
WBS-1.1 / DEL-001F-001SI developernoneSource access; revision confirmedTBD — effort-model.csv (commercial stage)3 days / 1 dev, illustrativeT-101: parameterized queries, injection-probe test passesPlanned
WBS-1.2 / DEL-002F-002, F-003SI developernoneDEC-005: client confirms whether out-of-scope filter protects /api/jobs/**TBD2 days / 1 dev, illustrativeT-102: health response redacted; POST /api/jobs/** returns 401/403Planned — DEC-005 needed
WBS-1.3 / DEL-003F-018SI developernoneNone requiredTBD4 days / 1 dev, illustrativeT-103: @SpringBootTest integration tests covering preview/save, batch rollback, auth, schema initPlanned
WBS-1.4 / DEL-004F-001, F-002, F-003, F-018SI senior reviewerWBS-1.1, WBS-1.2, WBS-1.3None — reviewer reads Wave 1 PR diffTBD2 days / 1 senior reviewer, illustrativeT-104: Wave 1 review record; no unresolved blocking comments; PRs approvedPlanned
WBS-1.5 / DEL-005F-004, F-008, F-009, F-019, F-020, F-021SI PMWBS-1.4DEC-001 (migration target); DEC-002 (discount boundary); DEC-003 (backup/DR policy); DEC-004 (InvoiceRunJobV2); DEC-006 (credit workflow design); GAP-001 (schedule environments); GAP-002 (CVE scan results); GAP-003 (data volume/SLA)TBD5 days / PM + client, illustrativeT-105: written decisions for DEC-001 to DEC-006 and GAP-001 to GAP-003; PM sign-off Wave 3 unblocked[held back]
WBS-2.1 / DEL-006F-001, F-003, F-004, F-017SI developerWBS-1.4[held back]TBD4 days / 1 dev, illustrative[held back]PROPOSED — separately gated; not authorized by this plan
WBS-2.2 / DEL-007F-001, F-003, F-004, F-017SI senior reviewerWBS-2.1Pilot authorization evidence; WBS-2.1 PR diffTBD1 day / 1 senior reviewer, illustrativeT-202: pilot review record; no unresolved blocking commentsPROPOSED — separately gated
WBS-2.3 / DEL-008F-001, F-003, F-004, F-017SI QAWBS-2.2Pilot authorization; deployed preview endpoint on target stackTBD1 day / 1 QA, illustrativeT-203: pilot-evidence.json with build, API, browser, rollback and unaffected-flow evidence; human acceptance pendingPROPOSED — separately gated
WBS-3.1 / DEL-009F-017, F-015SI developerWBS-1.5DEC-001 confirmed; GAP-002 CVE scan results; DEC-004 InvoiceRunJobV2 dispositionTBD5 days / 1 dev, illustrativeT-301: builds on Java 21/SB3; no javax.servlet.*; Class.newInstance() replaced; CVE scan result recorded[held back]
WBS-3.2 / DEL-010F-014SI developerWBS-3.1None requiredTBD3 days / 1 dev, illustrativeT-302: Flyway V1__init.sql present; schema managed via migration history; @PostConstruct DDL-freePlanned
WBS-3.3 / DEL-011F-013, F-016SI developerWBS-3.2DEC-003 (backup/DR); GAP-003 (data volume); DEC-001 confirmedTBD4 days / 1 dev, illustrativeT-303: SERIAL/SEQUENCE in V2__sequences.sql; TO_CHAR replaces FORMATDATETIME; integration tests vs Testcontainers PostgreSQL passPlanned
WBS-3.4 / DEL-012F-005, F-006SI developerWBS-3.3None requiredTBD3 days / 1 dev, illustrativeT-304: @Transactional on batch methods; mid-batch failure test asserts DB state unchanged; job wrappers log structured errorPlanned
WBS-3.5 / DEL-013F-012, F-022, F-023SI developerWBS-3.3Client deployment topology decision (for F-023)TBD3 days / 1 dev, illustrativeT-305: synchronized removed from save()/requestCredit(); JobContext interface present; /api base path documented or configurablePlanned
WBS-3.6 / DEL-014F-009, F-010SI developerWBS-1.5DEC-004 (InvoiceRunJobV2 disposition and LegacyInvoicePrinter scan result)TBD2 days / 1 dev, illustrativeT-306: InvoiceRunJobV2 disposed per DEC-004; LegacyInvoicePrinter scan result recorded; classpath stub testPlanned — blocked on DEC-004
WBS-3.7 / DEL-015F-020, F-021SI developerWBS-3.3, WBS-1.5DEC-003 (backup policy); GAP-001 (schedule environments); GAP-003 (data volume)TBD3 days / 1 dev, illustrativeT-307: demo.today absent from prod config; application-prod.properties present; backup/restore test documentedPlanned — blocked on DEC-003, GAP-001, GAP-003
WBS-3.8 / DEL-016F-004, F-011SI developerWBS-1.5, WBS-1.3DEC-002 (discount boundary written decision)TBD2 days / 1 dev, illustrativeT-308: aligned discount boundary per DEC-002; GST inline literal replaced; parameterized test qty=99/100/101 passes both pathsPlanned — blocked on DEC-002
WBS-3.9 / DEL-017F-019SI tech leadWBS-1.5GAP-002 scan results; all business rule decisions (DEC-001 to DEC-006)TBD3 days / 1 tech lead, illustrativeT-309: OpenAPI spec at /api-docs; business rules doc (discount, GST, line limit, credit policy); operations runbookPlanned
WBS-3.10 / DEL-018F-005, F-006, F-009, F-010, F-012, F-013, F-014, F-015, F-016, F-017, F-019, F-020, F-021, F-022, F-023SI tech leadWBS-3.4, WBS-3.5, WBS-3.6, WBS-3.7, WBS-3.8, WBS-3.9None — tech lead reviews Wave 3 deliverablesTBD3 days / 1 tech lead, illustrativeT-310: Wave 3 tech lead review record; no unresolved blocking comments; PRs approvedPlanned
WBS-3.11 / DEL-019F-004, F-005, F-011, F-012, F-013, F-014, F-016, F-017, F-018SI QAWBS-3.10PostgreSQL Testcontainers environment; representative seed dataTBD3 days / 1 QA, illustrativeT-311: all tests pass on Java 21/SB3/PostgreSQL; discount, batch rollback, sequence, FORMATDATETIME tests confirmedPlanned
WBS-4.1 / DEL-020F-008SI developerWBS-3.10DEC-006 (credit workflow design written decision)TBD3 days / 1 dev, illustrativeT-401: credit workflow per DEC-006; PENDING+approval test OR auto-approve sign-off documentedPlanned — blocked on DEC-006
WBS-4.2 / DEL-021F-007SI developerWBS-3.10None requiredTBD1 day / 1 dev, illustrativeT-402: desk.js disables submit at 50 lines; inline error message presentPlanned
WBS-4.3 / DEL-022F-001 through F-023 (all 23)SI PMWBS-4.1, WBS-4.2All DEC/GAP decisions confirmed closed; Wave 1, 3, 4 review recordsTBD2 days / 1 PM, illustrative[held back]Planned

Finding disposition table

One row per accepted finding ID. mapped rows list the exact WBS set carrying the finding. No deferred or accepted-risk dispositions are claimed without client-evidenced authority — all findings are mapped to work packages, with client decisions noted as prerequisites on the relevant tasks.

Finding IDDispositionWBS IDsOwnerReason
F-001mappedWBS-1.1, WBS-1.4, WBS-2.1, WBS-2.2, WBS-2.3, WBS-4.3nullnull
F-002mappedWBS-1.2, WBS-1.4, WBS-4.3nullnull
F-003mappedWBS-1.2, WBS-1.4, WBS-2.1, WBS-2.2, WBS-2.3, WBS-4.3nullnull
F-004mappedWBS-1.5, WBS-2.1, WBS-2.2, WBS-2.3, WBS-3.8, WBS-3.11, WBS-4.3nullnull
F-005mappedWBS-3.4, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-006mappedWBS-3.4, WBS-3.10, WBS-4.3nullnull
F-007mappedWBS-4.2, WBS-4.3nullnull
F-008mappedWBS-1.5, WBS-4.1, WBS-4.3nullnull
F-009mappedWBS-1.5, WBS-3.6, WBS-3.10, WBS-4.3nullnull
F-010mappedWBS-3.6, WBS-3.10, WBS-4.3nullnull
F-011mappedWBS-3.8, WBS-3.11, WBS-4.3nullnull
F-012mappedWBS-3.5, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-013mappedWBS-3.3, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-014mappedWBS-3.2, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-015mappedWBS-3.1, WBS-3.10, WBS-4.3nullnull
F-016mappedWBS-3.3, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-017mappedWBS-3.1, WBS-2.1, WBS-2.2, WBS-2.3, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-018mappedWBS-1.3, WBS-1.4, WBS-3.11, WBS-4.3nullnull
F-019mappedWBS-1.5, WBS-3.9, WBS-3.10, WBS-4.3nullnull
F-020mappedWBS-1.5, WBS-3.7, WBS-3.10, WBS-4.3nullnull
F-021mappedWBS-1.5, WBS-3.7, WBS-3.10, WBS-4.3nullnull
F-022mappedWBS-3.5, WBS-3.10, WBS-4.3nullnull
F-023mappedWBS-3.5, WBS-3.10, WBS-4.3nullnull

No findings are deferred or accepted_risk. All client-decision-gated findings are planned with an explicit client-input prerequisite on their earliest carrying task; the decision is not assumed to exist until evidenced by a written owner decision as described in WBS-1.5.


Team, responsibilities and sign-offs

RoleAccountable work / decisionInputs suppliedReview or sign-off evidenceSeparation / pending authority
Client technical + security ownerDEC-001 migration target selection; DEC-004 InvoiceRunJobV2 disposition; DEC-005 security filter confirmationWritten decisions before WBS-1.5 closesT-105 decision record; WBS-1.5 PM sign-offHuman approval pending; role label is responsibility only
Business / product ownerDEC-002 discount boundary (qty=100); DEC-006 credit workflow designWritten decisions before WBS-3.8 and WBS-4.1 respectivelyT-105 decision recordHuman approval pending
Client operations + data ownerDEC-003 backup/DR policy; GAP-001 schedule environment; GAP-003 data volume/SLAWritten decisions and inventory before WBS-3.7 and WBS-3.3T-105 decision recordHuman approval pending
Client security ownerGAP-002 CVE/SAST scan results; DEC-005SAST scanner output before WBS-3.1T-105 decision record + scan artifactHuman approval pending; scan not performed in assessment
SI developerImplementation tasks WBS-1.1, WBS-1.2, WBS-1.3, WBS-2.1, WBS-3.1 through WBS-3.8, WBS-4.1, WBS-4.2[held back]Git commits / PRs per WBS task; test outputUnauthenticated role label; one developer per task assumed
SI senior reviewerWBS-1.4, WBS-2.2 — independent review of Wave 1 and pilot changesWave 1/2 PR diffs and test resultsT-104, T-202 review records with no unresolved blocking commentsMust be independent of implementation author; demo identity separation not authenticated
SI tech leadWBS-3.9 documentation; WBS-3.10 integration review of Wave 3All Wave 3 deliverables; client decisionsT-309 documentation artifact; T-310 review recordUnauthenticated role label; must review changes not authored by this role
SI QAWBS-2.3 pilot verification; WBS-3.11 full regression; WBS-4.3 final traceabilityTest environment; seed data; Wave 2/3/4 deliverablesT-203 pilot-evidence.json; T-311 regression recordIndependent from development; demo identity not authenticated
SI PM[held back]All client decisions; Wave 1, 3, 4 review records[held back]PM does not author code; traceability and cadence responsibility

Critical dependency chain

[a sentence held back] Bob does not execute it). The following chain is the expected critical path derived from the predecessor structure and the longest elapsed-day sequence. Output label: relative_illustrative — no client calendars or staffing are known.

Critical path (longest elapsed-day sequence):

WBS-1.3 (4d) → WBS-1.4 (2d) → WBS-1.5 (5d) → WBS-3.1 (5d) → WBS-3.2 (3d) → WBS-3.3 (4d) → WBS-3.4 (3d) or WBS-3.7 (3d) or WBS-3.8 (2d) or WBS-3.5 (3d) → WBS-3.10 (3d) → WBS-3.11 (3d) → WBS-4.1 (3d) → WBS-4.3 (2d)

Minimum elapsed days (critical path, single-resource model): approximately 41 working days assuming no calendar gaps and sequential resource assignment. Independent Wave 1 branches (WBS-1.1, WBS-1.2 in parallel with WBS-1.3) can compress the Wave 1 end date if two developers are available simultaneously.

Hidden here: 2 sentences our checks hold back.

[a sentence held back] Bob does not execute the validator.


Prerequisite delay review

Hidden here: a sentence our checks hold back.

Delayed prerequisiteAdded elapsed days[held back][held back]Affected WBS IDsRequired owner response
[held back]+5Wave 3 start (WBS-3.1) at day 14Wave 3 start at day 19WBS-3.1, WBS-3.2, WBS-3.3, WBS-3.4, WBS-3.5, WBS-3.6, WBS-3.7, WBS-3.8, WBS-3.9, WBS-3.10, WBS-3.11, WBS-4.1, WBS-4.2, WBS-4.3Client decision owners must commit to a decision-closure meeting within the Wave 1 review window; SI PM to escalate if 3 working days elapse without response
[held back]+10Wave 3 start at day 14Wave 3 start at day 24All Wave 3 and Wave 4 tasksRevised delivery window required; SI PM to reissue illustrative schedule

[a sentence held back] Wave 1 security tasks (WBS-1.1, WBS-1.2, WBS-1.3) also remain unaffected.

Hidden here: a sentence our checks hold back.


Acceptance, responsibilities and client inputs

Objective inspection methods: Exit evidence for each WBS is a test ID (T-NNN) that names a specific observable artifact (passing test, code-level assertion, configuration review, written decision record, or review record with no unresolved blocking comments). No WBS exit is accepted on delivery team self-assessment alone; each requires either an independent review record (senior reviewer / tech lead / QA) or a written client decision.

Independent review roles: WBS-1.4 (senior reviewer, independent of Wave 1 implementation authors); WBS-2.2 (senior reviewer, independent of WBS-2.1 author); WBS-3.10 (tech lead, independent of Wave 3 development authors); WBS-3.11 (QA, independent of all development). Role separation is declared; it is not authenticated — demo personas may share an account. An authenticated approval requires a named human client or SI authority, which remains pending.

Required environments and seeds:

  • [a sentence held back] Java 21 / Spring Boot 3 build environment.
  • Wave 3 DB migration: PostgreSQL Testcontainers available in CI; client data volume information from GAP-003.
  • Hidden here: a sentence our checks hold back.

SOW linkage: WBS IDs and DEL IDs in this plan are the basis for SOW deliverable definitions in sow.md. [a sentence held back] SOW status: Draft—synthetic demonstration / nonbinding / unpriced.

Approval status: All approvals pending. No production acceptance is inferred from a passing test or nominal native tool success.


Delivery controls and stakeholder visibility

Git checkpoints (simulated — labelled as synthetic; not authenticated GitHub actions):

WBS IDSimulated branch / checkpointTicket reference (synthetic)Wiki / decision record
WBS-1.1feature/WBS-1.1-sql-injection-fixTICKET-101DEC-WIKI-001
WBS-1.2feature/WBS-1.2-security-endpoint-hardeningTICKET-102DEC-WIKI-002
WBS-1.3feature/WBS-1.3-test-suite-foundationTICKET-103DEC-WIKI-003
WBS-1.4[held back]TICKET-104REVIEW-104
WBS-1.5[held back]TICKET-105DEC-WIKI-105
WBS-2.xfeature/WBS-2.x-pilot-preview-service (gated)TICKET-201-203PILOT-AUTH-RECORD
WBS-3.1feature/WBS-3.1-java21-springboot3-upgradeTICKET-301DEC-WIKI-301
WBS-3.2feature/WBS-3.2-flyway-migrationTICKET-302DEC-WIKI-302
WBS-3.3feature/WBS-3.3-postgres-sequences-dialectsTICKET-303DEC-WIKI-303
WBS-3.4–3.9feature/WBS-3.x-* per taskTICKET-304 to 309DEC-WIKI-304 to 309
WBS-3.10–3.11[held back]TICKET-310-311REVIEW-310
WBS-4.1–4.3feature/WBS-4.x-* per taskTICKET-401-403DEC-WIKI-401

These references are simulated connector records for the synthetic delivery demonstration. They do not represent authenticated actions in any live GitHub or Atlassian system. Actual tooling and project board configuration are client-owned.

Status cadence: Weekly status review by SI PM against WBS milestone table. Escalation path: SI PM → SI tech lead → client technical owner for unresolved blockers. [a sentence held back]


Risks, exclusions and change control

Explicit uncertainty drivers:

UncertaintyFinding / decisionImpact on planRequired response
Client decisions DEC-001 to DEC-006 not receivedF-004, F-008, F-009, F-019, F-020, F-021WBS-3.1 through WBS-4.3 cannot start until WBS-1.5 closesPM escalation at day 3 of WBS-1.5 window
GAP-002 CVE scan not performedF-017WBS-3.1 Java upgrade may uncover critical CVE requiring immediate hot-fix scopeIntegrate scan before WBS-3.1 starts; revised plan if critical CVE found
GAP-003 data volume unknownF-013, F-021H2→PostgreSQL cutover window and backup sizing cannot be confirmedClient operations owner provides volume metrics before WBS-3.3
Jakarta EE namespace migration surfaceF-017Actual incompatibilities unknown until WBS-3.1 compile runScope may increase by up to 3 days depending on transitive Spring dependencies
InvoiceRunJobV2 disposition (DEC-004)F-009If 'complete the stub' decision is made, WBS-3.6 scope increases significantlyOwner decision required at WBS-1.5; estimate revised if completion path selected
Pilot authorizationWave 2Pilot is proposed; absence of authorization leaves Wave 2 at NO-GO[held back]

Scope exclusions:

  • All option 1 (retain-and-contain) remediation-only paths beyond Wave 1 security fixes
  • Kotlin migration (F-018 test baseline must be complete; not in scope until separately authorized)
  • TypeScript frontend migration (F-023 topology decision pending; not in current WBS)
  • Full microservice decomposition (pilot validates extract boundary only; full decomposition not in scope)
  • FastAPI and ASP.NET Core rewrites (planning-only per assessment.md and target-annexes.md)
  • Security DAST/SAST execution (GAP-002 — client-owned; results feed WBS-3.1)
  • Performance and load testing
  • Production deployment or go-live activities
  • Infrastructure provisioning, cloud configuration, or CI/CD pipeline setup beyond code-level changes

Change control: Any change to migration target profile (DEC-001), scope additions, or calendar/capacity changes require revised assumptions, rerun of affected dependency and effort checks, and SI PM + client technical owner sign-off. [a sentence held back]

[a sentence held back] Wave 2 pilot is NO-GO pending named authorization. Wave 3 and Wave 4 are planning-only pending WBS-1.5 client decisions.

Back to the four documents

Bob's document · written by IBM Bob

Statement of work

Bob's draft: scope, deliverables, client dependencies, acceptance and change control. It carries no price; the price comes from the SI's own tool.

Migration assessmentEffort modelImplementation planStatement of work

Bob wrote this inside our test setup. Our automatic checks hold back 26 sentences and 19 table cells, each place marked: most mention that setup, a few only share a word with it. The file's metadata block is left out. Every other word is Bob's, unchanged.

Our check: Bob's totals line says 237 / 332 / 472 hours, but his own deliverable rows add up to 252 / 362 / 512, the effort model's totals. The SI's pricing tool uses the effort model. This is the kind of slip a reviewer catches before a SOW goes out; Bob's text is shown as he wrote it.

Draft — synthetic demonstration / nonbinding / unpriced. Client approval, signatures, rate card, legal terms, currency, tax/expense treatment and formal acceptance are pending / TBD. This document establishes no production migration commitment, no authorized spending authority, and no client contractual obligation. The Java invoice-preview pilot is separately gated and is not authorized by the existence of this draft.


1. Purpose, boundary and exclusions

Hidden here: a sentence our checks hold back.

[a sentence held back] F-001 (SQL injection), F-002 (credential exposure) and F-003 (unauthenticated batch triggers) must be remediated and independently verified before any production exposure. [a sentence held back]

Migration path: Option 2 — bounded migration (Spring Boot 3 / Java 21 + H2 → PostgreSQL) as recommended in assessment.md. Other assessed paths (Option 1 retain-and-contain beyond Wave 1 security fixes, Kotlin migration, TypeScript frontend migration, full microservice decomposition, FastAPI rewrite, ASP.NET Core rewrite) are excluded from this SOW unless separately authorized.

Pilot: The Java invoice-preview pilot (WBS-2.1 through WBS-2.3, DEL-006 through DEL-008) covers Desk.calculate() only — no save, batch, job paths, database migration, authentication system, browser SPA rebuild, or production deployment. It is a pilot-future-forecast excluded from enterprise effort totals. [a sentence held back]

Explicit exclusions:

  • All Option 1 (retain-and-contain) remediation paths beyond Wave 1 critical security fixes (F-001, F-002, F-003)
  • Kotlin migration
  • TypeScript/frontend build toolchain migration (F-023 deployment topology decision pending)
  • Full microservice decomposition beyond the separately gated pilot boundary
  • FastAPI and ASP.NET Core rewrites (planning-only per target-annexes.md)
  • Security DAST/SAST execution (GAP-002 — client-owned; results are a Wave 3 prerequisite, not a deliverable here)
  • Performance and load testing
  • Production deployment, infrastructure provisioning, cloud configuration, CI/CD pipeline setup
  • Any work dependent on unresolved decisions (DEC-001 through DEC-006, GAP-001 through GAP-003) beyond what is gated in WBS-1.5

2. Deliverables and artifact crosswalk

The six primary report-pack artifacts produced for this run are listed first. Implementation deliverables (DEL-001 through DEL-022) follow.

2.1 Report pack artifacts (this run)

ArtifactPathAuthorStatus
assessment.md[held back]IBM BobPending independent review
target-annexes.md[held back]IBM BobPending independent review
effort-model.csv[held back][held back][held back]
implementation-plan.md[held back]IBM BobPending independent review
sow.md (this file)[held back]IBM BobPending independent review
[held back][held back]IBM BobPending independent review

2.2 Implementation deliverables

Effort totals are unpriced forecasts. Hours shown are included-scope only (pilot rows are excluded). [a sentence held back]

DEL IDWBS IDTask nameRoleFinding IDsLow hCentral hHigh hExit evidenceStatus
DEL-001WBS-1.1SQL injection remediationSI developerF-001152130T-101planned
DEL-002WBS-1.2Security endpoint hardeningSI developerF-002, F-003101420T-102planned — DEC-005 needed
DEL-003WBS-1.3Test suite foundationSI developerF-018202840T-103planned
DEL-004WBS-1.4Wave 1 independent reviewSI senior reviewerF-001, F-002, F-003, F-01881216T-104planned
DEL-005WBS-1.5[held back]SI PMF-004, F-008, F-009, F-019, F-020, F-02181624T-105planned
DEL-006WBS-2.1Java pilot — implementationSI developerF-001, F-003, F-004, F-017———T-201EXCLUDED / proposed-gated
DEL-007WBS-2.2Java pilot — reviewSI senior reviewerF-001, F-003, F-004, F-017———T-202EXCLUDED / proposed-gated
DEL-008WBS-2.3Java pilot — QA verificationSI QAF-001, F-003, F-004, F-017———T-203EXCLUDED / proposed-gated
DEL-009WBS-3.1Java 21 / Spring Boot 3 upgradeSI developerF-017, F-015253550T-301planned — blocked on WBS-1.5
DEL-010WBS-3.2Flyway schema migration frameworkSI developerF-014152130T-302planned
DEL-011WBS-3.3PostgreSQL dialect and sequencesSI developerF-013, F-016202840T-303planned
DEL-012WBS-3.4Batch atomicity and job error handlingSI developerF-005, F-006152130T-304planned
DEL-013WBS-3.5Architecture decouplingSI developerF-012, F-022, F-023152130T-305planned
DEL-014WBS-3.6Dead-code dispositionSI developerF-009, F-010101420T-306planned — blocked on DEC-004
DEL-015WBS-3.7Production readiness and backupSI developerF-020, F-021152130T-307planned — blocked on DEC-003, GAP-001
DEL-016WBS-3.8Discount alignment and GST deduplicationSI developerF-004, F-011101420T-308planned — blocked on DEC-002
DEL-017WBS-3.9API and business rule documentationSI tech leadF-019121824T-309planned — blocked on WBS-1.5
DEL-018WBS-3.10Wave 3 tech lead integration reviewSI tech leadF-005, F-006, F-009, F-010, F-012, F-013, F-014, F-015, F-016, F-017, F-019, F-020, F-021, F-022, F-023121824T-310planned
DEL-019WBS-3.11Full regression on Java 21 / SB3 / PostgreSQLSI QAF-004, F-005, F-011, F-012, F-013, F-014, F-016, F-017, F-018121824T-311planned
DEL-020WBS-4.1Credit approval workflow per DEC-006SI developerF-008152130T-401planned — blocked on DEC-006
DEL-021WBS-4.2Client-side 50-line limit validationSI developerF-0075710T-402planned
DEL-022WBS-4.3Final PM delivery review and handoffSI PMF-001 through F-023 (all 23)101420T-403planned

Hidden here: a sentence our checks hold back.

ScenarioLow hoursCentral hoursHigh hours
Included (enterprise; excludes pilot DEL-006/007/008)237332472
Pilot only (excluded; proposed-gated DEL-006/007/008)284056

Hidden here: 2 sentences our checks hold back.

Effort basis: All hours are assessment-forecasts derived from plan elapsed-day durations, role productivity assumptions, and finding scope. No actual pilot hours exist. The effort model will be updated if the separately authorized pilot produces measured actuals that are comparable to enterprise tasks. Monetary fields remain not priced — no authorized rate card, currency, tax/expense treatment or conversion is documented. Do not use these hours for client quotation purposes.

2.3 Finding disposition table

[a sentence held back] No finding is deferred or accepted-risk without a client-evidenced owner decision. Findings requiring client decisions as prerequisites are noted in the owner column with the decision reference; the decision itself is not assumed to exist until evidenced by a written owner decision at WBS-1.5 (T-105).

Finding IDDispositionWBS IDsOwnerReason
F-001mappedWBS-1.1, WBS-1.4, WBS-2.1, WBS-2.2, WBS-2.3, WBS-4.3nullnull
F-002mappedWBS-1.2, WBS-1.4, WBS-4.3nullnull
F-003mappedWBS-1.2, WBS-1.4, WBS-2.1, WBS-2.2, WBS-2.3, WBS-4.3nullnull
F-004mappedWBS-1.5, WBS-2.1, WBS-2.2, WBS-2.3, WBS-3.8, WBS-3.11, WBS-4.3nullnull
F-005mappedWBS-3.4, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-006mappedWBS-3.4, WBS-3.10, WBS-4.3nullnull
F-007mappedWBS-4.2, WBS-4.3nullnull
F-008mappedWBS-1.5, WBS-4.1, WBS-4.3nullnull
F-009mappedWBS-1.5, WBS-3.6, WBS-3.10, WBS-4.3nullnull
F-010mappedWBS-3.6, WBS-3.10, WBS-4.3nullnull
F-011mappedWBS-3.8, WBS-3.11, WBS-4.3nullnull
F-012mappedWBS-3.5, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-013mappedWBS-3.3, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-014mappedWBS-3.2, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-015mappedWBS-3.1, WBS-3.10, WBS-4.3nullnull
F-016mappedWBS-3.3, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-017mappedWBS-3.1, WBS-2.1, WBS-2.2, WBS-2.3, WBS-3.10, WBS-3.11, WBS-4.3nullnull
F-018mappedWBS-1.3, WBS-1.4, WBS-3.11, WBS-4.3nullnull
F-019mappedWBS-1.5, WBS-3.9, WBS-3.10, WBS-4.3nullnull
F-020mappedWBS-1.5, WBS-3.7, WBS-3.10, WBS-4.3nullnull
F-021mappedWBS-1.5, WBS-3.7, WBS-3.10, WBS-4.3nullnull
F-022mappedWBS-3.5, WBS-3.10, WBS-4.3nullnull
F-023mappedWBS-3.5, WBS-3.10, WBS-4.3nullnull

No findings are deferred or accepted-risk. All client-decision-dependent findings (F-004, F-008, F-009, F-019, F-020, F-021) are planned with an explicit written-decision prerequisite at WBS-1.5. [a sentence held back]


3. Effort and relative schedule

[a sentence held back] The indicative totals in Section 2.2 are pre-render arithmetic only.

Estimate method: Assessment-forecast. Hours per row are total person-hours for that deliverable, derived from plan elapsed-day durations (from implementation-plan.md) and per-role productivity assumptions (developer: 5–10 h/day central 7h; reviewer/tech lead/QA: 4–8 h/day central 6h; PM facilitation: lighter personal hours relative to elapsed-day window). No measured pilot data is available. The estimate is unpriced and should not be used for client financial commitment without an authorized rate card.

Monetary fields: Not priced. No rate card, currency, tax/expense treatment or conversion has been authorized. [2 sentences held back]

Relative schedule basis: All duration figures are elapsed working-day assumptions from implementation-plan.md (labelled relative_illustrative). Client calendars, staffing capacity and environment availability are unknown. The critical path runs approximately 41 working days under a single-resource model with sequential execution (see implementation-plan.md critical dependency chain). Independent Wave 1 branches (WBS-1.1, WBS-1.2, WBS-1.3) may be compressed with parallel developer assignment.

Milestones:

MilestoneGating WBSEarliest allowed start[held back]Unresolved prerequisite
Wave 1 completeWBS-1.4 (T-104)Day 1 (no predecessor)WBS-1.1, WBS-1.2, WBS-1.3 all exitNone for WBS-1.1/1.3; DEC-005 for WBS-1.2
[held back]WBS-1.5 (T-105)After T-104WBS-1.4 exitDEC-001 to DEC-006, GAP-001 to GAP-003 — all written decisions required
Wave 3 start unblockedWBS-3.1After T-105WBS-1.5 exitBlocked until all WBS-1.5 decisions confirmed
Wave 3 completeWBS-3.11 (T-311)After WBS-3.10Full regression passPostgreSQL Testcontainers environment; client seed data
Pilot authorized startWBS-2.1After T-104 + named authorization[held back][held back]
Final handoffWBS-4.3 (T-403)After WBS-4.1, WBS-4.2All Wave 4 exitsAll 23 findings closed; all DEC/GAP decisions confirmed

[a sentence held back] A 10-day slip delays Wave 3 start by 10 days. Pilot branch (WBS-2.x) is unaffected by WBS-1.5 delays (separately gated). [a sentence held back]


4. Responsibilities and client dependencies

Owner roleResponsibilityRequired input / environmentDependency WBSEvidence of readiness
Client technical + security ownerDEC-001 migration target selection; DEC-004 InvoiceRunJobV2 disposition; DEC-005 security filter confirmationWritten decisions before WBS-1.5 closesWBS-1.5T-105 written decision record
Business / product ownerDEC-002 discount boundary at qty=100 (>=100 or >100); DEC-006 credit approval workflow designWritten decisions before WBS-3.8 and WBS-4.1 respectivelyWBS-1.5, WBS-3.8, WBS-4.1T-105 decision record
Client operations + data ownerDEC-003 backup/DR policy; GAP-001 schedule.enabled environment confirmation; GAP-003 data volume and SLAWritten decisions and inventory before WBS-3.3 and WBS-3.7WBS-1.5, WBS-3.3, WBS-3.7T-105 decision record
Client security ownerGAP-002 SAST / CVE scanner output (F-017, F-001 affectedness)SAST scanner results before WBS-3.1WBS-1.5, WBS-3.1T-105 decision record + scan artifact
Client (all owners)Written closure of all 8 open decisions / gaps (DEC-001 to DEC-006, GAP-001 to GAP-003)Before WBS-1.5 exitWBS-1.5[held back]
SI developerImplementation tasks WBS-1.1, WBS-1.2, WBS-1.3, WBS-3.1 through WBS-3.8, WBS-4.1, WBS-4.2[held back]Per WBS predecessorsGit commits / PRs per WBS task; test output per T-NNN
SI senior reviewerWBS-1.4 (Wave 1 independent review); WBS-2.2 (pilot review if authorized)Wave 1 / pilot PR diffs and test resultsWBS-1.4, WBS-2.2T-104, T-202 review records with no unresolved blocking comments
SI tech leadWBS-3.9 documentation; WBS-3.10 Wave 3 integration reviewAll Wave 3 deliverables; client decisionsWBS-3.9, WBS-3.10T-309 documentation artifact; T-310 review record
SI QAWBS-2.3 pilot verification (if authorized); WBS-3.11 full regression; WBS-4.3 traceabilityTest environment; seed data; Wave 2/3/4 deliverablesWBS-2.3, WBS-3.11, WBS-4.3T-203 pilot-evidence.json; T-311 regression record; T-403 delivery record
SI PM[held back]All client decisions; all wave review recordsWBS-1.5, WBS-4.3[held back]

[a sentence held back] Wave 3 DB migration work requires PostgreSQL Testcontainers in CI; client must confirm availability before WBS-3.3. Pilot requires a separately authorized run environment.


[Section heading held back]

Reviewer role: SI senior reviewer (Wave 1, WBS-1.4); SI tech lead (Wave 3, WBS-3.10); SI QA (Wave 2 pilot if authorized, WBS-2.3; full regression WBS-3.11; final traceability WBS-4.3). [a sentence held back]

Review window (proposed — pending agreement): Wave 1 review (WBS-1.4): 2 working days after all Wave 1 PRs are submitted. Wave 3 review (WBS-3.10): 3 working days after all Wave 3 PRs are submitted. Pilot review (WBS-2.2, if authorized): 1 working day. Final traceability (WBS-4.3): 2 working days.

Objective inspection method per deliverable: Each deliverable exit is a named test ID (T-NNN) referencing a specific observable artifact — a passing automated test assertion, a configuration file inspection, a written owner decision record, or a review record with no unresolved blocking comments. No deliverable exit is accepted on delivery-team self-assessment alone.

[a sentence held back] The discrepancy is identified by artifact version and evidence. [4 sentences held back]

Acceptance status: Pending. [a sentence held back] Authenticated acceptance by a named human authority is required before any production deployment, spending authorization, or contractual commitment.

Authentication limits: Role labels in this SOW (client technical owner, business/product owner, SI developer, etc.) are responsibility declarations only. The synthetic delivery simulation does not authenticate the identity of any individual. Demo personas may share one account. [a sentence held back]


6. Change control and commercial terms

Change control triggers: Any change to the following requires a documented change request, impact assessment, affected WBS revision, effort re-forecast, and authorized human decision before scope expansion:

  • Migration target profile (DEC-001) — changes Option 2 scope or introduces an excluded option
  • Addition of any excluded scope (Kotlin, TypeScript, microservices decomposition, FastAPI/ASP.NET Core, DAST/SAST execution, production deployment)
  • Change to any client calendar, capacity or environment assumption
  • Change to acceptance criteria for any WBS deliverable
  • Discovery of additional security findings requiring Wave 1 or critical-path expansion
  • InvoiceRunJobV2 completion decision (DEC-004) increasing WBS-3.6 scope
  • Any authorization of the pilot (WBS-2.x) or enterprise Wave 3+ beyond current planned state

Commercial terms:

FieldValue
PriceNot priced — no authorized rate card
Currency[held back]
Tax / expensesTBD — pending authorized review
Payment termsTBD — pending
LiabilityTBD — pending
Governing lawTBD — pending
SignaturesPending — no authorized human signature

[a sentence held back] It is not an SI delivery price and must not be presented to a client as a project cost.

Synthetic/illustrative rate scenarios may be produced to demonstrate the effort model CSV format; they are labelled as illustrative and cannot authorize a client quote or establish a billing commitment. Any client-facing commercial proposal requires exact authorized rates, currency, tax treatment and explicit human approval evidence.


7. Unresolved decisions and open items

The following decisions and gaps from assessment.md remain open at the time of this draft. They are prerequisites for the indicated deliverables and must be resolved before those deliverables can be scoped or started.

Decision / gap IDOwner roleRequired inputBlocksEvidence that closes it
DEC-001Client technical + security ownerSelect migration target profile (Option 1, 2 or 3)WBS-3.1 and all Wave 3 / Wave 4 tasks; pilot authorizationWritten owner decision
DEC-002Business / product ownerConfirm discount boundary at qty=100: >=100 or >100? (F-004)WBS-3.8, WBS-3.11Written business rule decision
DEC-003Client operations + data ownerDefine backup/DR policy for H2 file database or confirm DB migration timeline (F-021)WBS-3.3, WBS-3.7Written backup policy or confirmed migration decision
DEC-004Client technical ownerConfirm InvoiceRunJobV2 disposition: remove, complete or replace? (F-009)WBS-3.1 source lock, WBS-3.6Owner decision and resulting source change
DEC-005Client security ownerConfirm whether any out-of-scope security filter currently protects /api/jobs/** (F-003)WBS-1.2 scopeConfirmed security configuration evidence
DEC-006Business / product ownerConfirm whether auto-approve credit is intentional or a gap (F-008)WBS-4.1Written design decision
GAP-001Client operations ownerConfirm whether demo.schedule.enabled is ever set to true in any deployed environment (EV-031)WBS-3.7 severity and scopeDeployment configuration inventory or environment scan
GAP-002Client security ownerCommission and return SAST / dependency vulnerability scan results (F-017, F-001)WBS-3.1 start (CVE scan required as prerequisite)Scanner output with affected CVE list
GAP-003Client operations ownerProvide production data volume, backup retention and SLA requirements (EV-028, EV-029)WBS-3.3, WBS-3.7 sizingOperational inventory or owner interview

All nine open items must be resolved before a production migration plan can be finalized and Wave 3 scoped. The SI PM will escalate if 3 working days elapse within the WBS-1.5 decision window without client response.

Back to the sample data

The sample data · synthetic

The team

Twelve made-up people: their role, seniority, skills and when they are free. Bob read them through the roster tool to staff each work package.

The teamThe timesheetsThe rate card

Synthetic. Made up for this demo: not real people, time records or prices. Used on the page at Map skills to team.

PersonRoleSenioritySkillsFree to work
SE-01Solution architectprincipalSolution architecture, Java 8, Java 21, Spring Boot 3, PostgreSQL, Application security50% from 2026-10-26 to 2026-12-18; 100% from 2027-01-04 to 2027-03-31
SE-02Solution architectseniorSolution architecture, Java 21, Spring Boot 3, CI/CD, Docker100% from 2026-11-02 to 2027-02-26
SE-03Migration engineerseniorJava 8, Java 21, Spring Boot 2, Spring Boot 3, Maven, JUnit 5100% from 2026-10-19 to 2027-01-29
SE-04Migration engineerseniorJava 21, Spring Boot 3, PostgreSQL, Flyway, JUnit 580% from 2026-11-16 to 2027-03-31
SE-05Migration engineermidJava 8, Java 21, Spring Boot 3, Maven, Docker100% from 2026-10-19 to 2026-12-11; 100% from 2027-01-11 to 2027-03-31
SE-06Migration engineermidJava 21, Spring Boot 3, PostgreSQL, Performance testing100% from 2026-12-01 to 2027-03-31
SE-07Migration engineerjuniorJava 21, Spring Boot 3, JUnit 5100% from 2026-10-19 to 2027-03-31
SE-08Database engineerseniorPostgreSQL, Flyway, Data migration, Performance testing60% from 2026-11-09 to 2027-01-22
SE-09Security engineerseniorApplication security, Spring Security, Java 21, Dependency scanning50% from 2027-01-04 to 2027-02-26
SE-10QA automation engineermidQA automation, Playwright, JUnit 5, API testing100% from 2026-10-26 to 2027-03-31
SE-11QA automation engineerjuniorQA automation, Playwright, Manual regression testing100% from 2026-11-02 to 2027-03-31
SE-12Delivery managerseniorDelivery management, Finance domain (invoicing and GST), Stakeholder reporting, Risk management50% from 2026-10-19 to 2027-03-31

Back to the sample data

The sample data · synthetic

The timesheets

A sample pilot's planned work packages and the time logged against them, entry by entry. It is the timesheet tool's own sample, not the Java 21 pilot on the page.

The teamThe timesheetsThe rate card

Synthetic. Made up for this demo: not real people, time records or prices. Used on the page at Calibrate velocity.

Planned for the pilot

Work packageWhat it coversPlanned hours
PILOT-WP-01Pilot set-up: toolchain, build pipeline and baseline test run40
PILOT-WP-02Upgrade of the invoice-preview module and its unit tests64
PILOT-WP-03Regression run and browser checks of the invoice preview32
PILOT-WP-04Rollback rehearsal and pilot close-out16

Time logged

EntryDatePersonRoleWork packageHoursWhat was done
TS-00012026-09-07Synthetic Engineer 03Migration engineerPILOT-WP-017.5Toolchain installation and build baseline
TS-00022026-09-07Synthetic Engineer 01Solution architectPILOT-WP-014.0Pilot scope walkthrough with the delivery team
TS-00032026-09-08Synthetic Engineer 03Migration engineerPILOT-WP-017.5Baseline test run and results capture
TS-00042026-09-08Synthetic Engineer 07Migration engineerPILOT-WP-016.0Workstation and repository set-up
TS-00052026-09-09Synthetic Engineer 03Migration engineerPILOT-WP-017.5Build pipeline configuration for the pilot branch
TS-00062026-09-09Synthetic Engineer 07Migration engineerPILOT-WP-016.0Build pipeline configuration for the pilot branch
TS-00072026-09-09Synthetic Engineer 01Solution architectPILOT-WP-014.0Pilot approach review
TS-00082026-09-10Synthetic Engineer 03Migration engineerPILOT-WP-027.5Module upgrade work
TS-00092026-09-10Synthetic Engineer 07Migration engineerPILOT-WP-027.5Module upgrade work
TS-00102026-09-11Synthetic Engineer 03Migration engineerPILOT-WP-027.5Module upgrade work
TS-00112026-09-11Synthetic Engineer 07Migration engineerPILOT-WP-027.5Module upgrade work
TS-00122026-09-11Synthetic Engineer 01Solution architectPILOT-WP-023.0Design review of upgrade changes
TS-00132026-09-14Synthetic Engineer 03Migration engineerPILOT-WP-027.5Module upgrade work
TS-00142026-09-14Synthetic Engineer 05Migration engineerPILOT-WP-027.5Paired code review
TS-00152026-09-14Synthetic Engineer 07Migration engineerPILOT-WP-027.5Module upgrade work
TS-00162026-09-15Synthetic Engineer 03Migration engineerPILOT-WP-027.0Unit test updates
TS-00172026-09-15Synthetic Engineer 05Migration engineerPILOT-WP-026.0Unit test updates
TS-00182026-09-15Synthetic Engineer 01Solution architectPILOT-WP-023.5Design review of upgrade changes
TS-00192026-09-16Synthetic Engineer 10Migration engineerPILOT-WP-037.5Regression suite run
TS-00202026-09-16Synthetic Engineer 11Migration engineerPILOT-WP-037.5Browser checks of the invoice preview
TS-00212026-09-17Synthetic Engineer 10Migration engineerPILOT-WP-037.5Regression suite run
TS-00222026-09-17Synthetic Engineer 03Migration engineerPILOT-WP-034.0Follow-up changes from verification
TS-00232026-09-17Synthetic Engineer 11Migration engineerPILOT-WP-036.0Browser checks of the invoice preview
TS-00242026-09-18Synthetic Engineer 01Solution architectPILOT-WP-032.0Verification sign-off review
TS-00252026-09-18Synthetic Engineer 05Migration engineerPILOT-WP-046.0Rollback rehearsal
TS-00262026-09-18Synthetic Engineer 07Migration engineerPILOT-WP-046.0Rollback rehearsal
TS-00272026-09-21Synthetic Engineer 12Solution architectPILOT-WP-043.0Pilot close-out notes
TS-00282026-09-21Synthetic Engineer 01Solution architectPILOT-WP-042.5Pilot close-out review

Back to the sample data

The sample data · synthetic

The rate card

The hourly rates the SI's pricing tool charges, and which rate each role in Bob's plan is charged at.

The teamThe timesheetsThe rate card

Synthetic. Made up for this demo: not real people, time records or prices. Used on the page at Price SOW and risk buffer.

Synthetic SGD demonstration rate card, valid 1 Jan to 31 Dec 2026.

Rates

Rate-card roleAn hour
Solution architectSGD 180.00
Migration engineerSGD 140.00

Which rate each role in Bob's plan is charged at

Role in Bob's planCharged as
SI tech leadSolution architect
SI senior reviewerSolution architect
SI PMSolution architect
SI developerMigration engineer
SI QAMigration engineer

On top of the hours, the tool adds a risk allowance of 15% once, to the whole deal.

Back to How it is built

How it is built · the partner kit

What happens when you run /assess

The kit is custom SI configuration for vanilla IBM Bob, kept as plain files you can read and change. Here is one command followed through it, step by step, with the file behind each step.

  1. You type the command

    You type /assess and a run name. The command file says which mode and skill Bob uses, the three files he writes and the two checks you run afterwards. In any other mode it stops and asks you to switch.

    .bob/commands/assess.md

  2. The mode sets what Bob may do

    The command needs the architect persona, si-architect. It may write only the Discover and Assess files under runs/<name>/ and has no shell. It is told to use the read-only tickets tools only in /assess, and the read-only checks tool on its own files.

    .bob/custom_modes.yaml

  3. The rules load

    The shared rules apply in every mode: the goal, where Bob may work, how he cites evidence and when he stops. The architect's own rules add its scope, inputs, outputs and stop conditions.

    .bob/rules/ .bob/rules-si-architect/AGENTS.md

  4. The skill brings the method

    The si-assess skill holds the assessment contract, three templates (the assessment, the target annexes and the findings file) and the findings checker. Bob also reads the SI's report standard and scoring rubric.

    .bob/skills/si-assess/ .bob/standards/

  5. Bob reads the evidence

    He reads what /discover left in the run folder and the code metrics you produced with the metrics tool after it; Bob measures nothing himself. He opens the source only to confirm a line he cites.

    app/ tools/metrics/complexity_metrics.py

  6. He checks the ticket history

    He uses the read-only tickets tool, which serves synthetic data, and asks you before every call. A ticket can back up a finding from the code; it never proves one on its own.

    .bob/mcp.json tools/mcp/data/tickets.json

  7. The guard checks every step

    Before every tool call, the guard lets Bob read only inside the workspace and write only inside runs/<name>/. It refuses every shell command and every delete, move or rename, and it allows only the ten read-only tools of the four mock servers and the read-only checks tool. It is a backstop for mistakes, not a security sandbox, and it works only when Bob starts the kit's hooks, which he does only in a folder you trust. The repository's guide shows how to check, as its first step.

    .bob/settings.json .bob/hooks/pre_tool_guard.py README.md

  8. Bob writes three files and checks them

    runs/<name>/reports/assessment.md, runs/<name>/reports/target-annexes.md and runs/<name>/data/findings.json. Then he runs your two checks on them through the read-only checks tool, which writes nothing, and corrects only what they name, for at most three rounds. He stops and tells you whether his last check passed.

    tools/checkers/checks_server.py

  9. Your checks decide

    You still run the findings check and the report check yourself. When both pass, the stage is done; when either prints errors, you paste them back and Bob fixes only what they name.

    .bob/skills/si-assess/scripts/validate_findings.py .bob/skills/si-collate/scripts/validate_report_quality.py

/discover runs in the same architect mode; /plan and /price each have their own. All three take the same path, each with its own skill and checks. The kit's own guide lists every file: .bob/README.md

To see these files change what Bob does, watch What Bob reads: probes in most of these places, with Bob's real replies.

Make it yours

Where each piece lives. The data is synthetic, and the kit's rules tell Bob so: real data means changing those rules too, and checking how your client's data may be handled.

Back to Watch it

Watch · The four commands in the Bob IDE

Bob in action

One bug followed from a line of the legacy code, through Discover, Assess, Plan and Price, to a line in the price: the same path as this page, in the IDE.

The video recreates a session in IBM Bob, sped up while Bob is working. Bob's replies are representative; every checker result on screen is real output from the kit's own checkers. It follows a separate run of the kit on the same code, and grey bars cover that run's own IDs and numbers. The figures that tell this story are the ones on this page, and they are illustrative, not a quote.

Chapters

  1. 0:00 Disclosure
  2. 0:06 Claim
  3. 0:16 Map
  4. 0:25 Set up
  5. 1:00 Discover
  6. 2:36 Assess
  7. 3:36 Plan
  8. 4:39 Price
  9. 6:13 Ending
  10. 6:32 Thread card
  11. 6:42 End card
The narration, in full

Disclosure

Illustrative: Bob's replies are representative, checker output is real, and grey bars cover run values.

Claim

A customer asks what it will take to modernise their legacy app, and what it will cost. Four Bob commands turn their code into the documents an SI prices from. We'll follow one bug from the code all the way to a line in the price.

Map

Each command runs in its own Bob mode, and each mode plays one role on an SI team. After every command, you run a checker.

Set up

First, set up once: open the kit in IBM Bob, trust the folder, and check Python.

IBM Bob opens a new folder in Restricted Mode. Trust it so the kit's hooks can run.

Check you have Python 3.11 or later. The hooks and checkers are plain Python.

This is Bob's chat: type the kit's commands here. It starts in Agent mode. The badge shows the credits you have left; the kit's README lists what its runs cost.

Discover

Discover. You choose a run name, acme-1 here, and Bob records evidence: a file and line for what it read, or a named gap for what it couldn't.

Still in Agent mode: Bob wrote nothing. /discover only works in SI Architect. Switch, then send it again.

The README says this line appears at the start. If it doesn't, check Python and folder trust.

Bob asks before it writes. The kit's guard keeps writes inside runs/<name>/, never app/, tools/ or .bob/.

Evidence points at Desk.java line 93. Each evidence row cites a file and line, or names a gap.

The bug we'll follow: search builds SQL from what a user types. Watch it pass through Assess, Plan and Price.

You run each check yourself. Zero means it passed. Bob's terminal also marks a passed command with a blue circle.

Assess

Assess. Bob turns the evidence into findings, an assessment report and one annex per migration target.

Bob asks because it can't see your terminal. It has no shell here, so it takes your word.

Bob also reads the customer's help-desk tickets. Read-only MCP: synthetic tickets and incidents, none about the search bug.

That evidence became a finding. Each finding cites its evidence. “Exploit not tested”: Bob says so.

Plan

Plan. Bob orders the work into packages and staffs them from the roster.

Bob staffs from the roster and flags gaps. Spring Security is free only from January.

The finding became a work package. It carries low, central and high hours. Done when search uses bound parameters.

The plan check passed: the packages have a valid order. A dependency cycle, or an unplaced finding, would fail it.

Price

Price. Bob writes effort rows and a draft statement of work. Your own tools do the money.

The work package is now an effort row, still citing Desk.java line 93. Hours only, under the rate-card role Migration engineer. Bob writes no money.

When a check fails, read what it names. We planted one wording slip; the output is real. Paste the error to Bob.

One line changed. Red is before, green is after: “not a client quotation” became “not a quote”. Nothing else moved.

Same rules, one line changed: it passes. Its limits stay: whether evidence supports each claim is for your senior reviewer.

Ending

That's the draft pack.

The draft pack is in runs/acme-1/reports/. Ready for senior review, not for a client.

The SI's engine turns the central hours into the price, not Bob. Synthetic rate; not a quote.

Thread card

One line of code, traced to one line of the price. When the customer asks why the SOW includes this fix, you can show them the line.

End card

To try it yourself: open the kit, trust the folder, pick SI Architect, and type slash discover with your run name.

Back to Watch it

Watch · The .bob folder and its config files

What Bob reads

Where a file sits, and what it says, changes what Bob does. Small probes show who Bob is, what he knows, what he can reach and what he may do; what we did not test, the video marks as IBM's documentation.

The IDE in the video is recreated, but every Bob reply in the terminal is real: small probes with IBM Bob Shell on a throwaway copy of the kit. The Bob IDE itself was not tested and may behave differently.

Chapters

  1. 0:00 Disclosure
  2. 0:08 Claim
  3. 0:18 Map
  4. 0:32 Who is Bob
  5. 1:04 What he knows
  6. 2:55 What he reaches
  7. 3:23 What he may do
  8. 4:04 Checklist
  9. 4:21 End card
The narration, in full

Disclosure

The IDE is recreated, but every Bob reply in the terminal is real: we ran small probes with IBM Bob Shell on a throwaway copy of the kit, shortening only task IDs and folder paths.

Claim

Where a file sits, and what it says, changes what Bob does. Every run answers four questions from your files: who Bob is, what he knows, what he can reach, and what he may do. We put a probe in most places and watched.

Map

Here are the files that shape a Bob run, and which question each one answers, as IBM documents them. The probes that follow test most of this. Files in the project override the same files in your global Bob folder.

Who is Bob

First: who is Bob right now? That's the mode, defined in custom modes dot yaml.

A mode is found by its slug. si-architect is the ID --mode needs; name is the label the IDE's menu shows.

Its tool groups set what Bob can do. edit matches only this stage's runs/ files. No command group: no shell (per IBM docs).

One wrong letter, and Bob Shell stops. Real output, no credits spent. This typo fails loudly; a later one won't.

What he knows

Second: what does he know? That depends on the root AGENTS file, the rules and the skills, and on exactly where each one sits.

The root AGENTS.md is already in his context. Our probe copy's version holds one codeword rule. Bob replied KIWI-ROOT-AGENTS, with no tool calls.

Bob's own /init drafted the kit's version. bob run doesn't expand /init, so we piped its prompt in. Bob read the repository and wrote a draft.

The draft gave away an answer. It names the app's deliberate bug. Since every run loads this file, /discover would be told.

Its last section fit only our test copy. A settings.json.off gotcha from the copy Bob read. Not in the kit.

We cut those two parts and kept the rest. The kit now ships this root AGENTS.md: context for every mode, no answers.

A rule for one mode lives in rules-<slug>/. This probe asks Bob to answer probe with a codeword, from rules-si-architect/.

It reaches SI Architect, not the planner. Same prompt in si-pm (SI Delivery Planner): no codeword, just its greeting.

This typo fails silently. In rules-architect/ (no si-) the rule never ran: Bob just echoed the prompt. No warning.

.bob/rules/ reaches every mode. Number the files: Bob combines rule files in name order (per IBM's docs).

A skill is known by its folder name. This SKILL.md says name: probe-mismatch; Bob lists probe-folder. Rename the folder, rename the skill.

What he reaches

Third: what can he reach? The files you hide from him, and the servers you connect.

Files in .bobignore are out of reach. In Ask mode, Bob's read tool refused the listed file and read the unlisted one.

No MCP tool is pre-approved. alwaysAllow is empty, and the guard hook's code allows only a short list of read-only MCP tools. MCP was off in our probes.

What he may do

Fourth: what may he do? Past the mode's edit rule, we watched two layers: the wording of the rules, and the guard hook.

Bob saw through a fake “authorised test”. He refused, citing the rules: labels are not approval, and made no tool calls.

Hooks run your scripts around Bob's tool calls. PreToolUse runs the guard before every tool call. Hooks load only in a trusted folder (per IBM docs).

Without the rules, the guard hook still blocked it. Bob tried once; the kit's pre_tool_guard.py refused the write.

Checklist

Before you trust a Bob run, check these. The ones marked observed, we saw Bob do. The others come from IBM's documentation.

End card

Try it yourself: put a codeword rule in a rules folder for your mode, and ask for it. If the codeword doesn't come back, Bob never saw the rule. Every reply in this video is Bob's own.

Back to Watch it

Watch · The same four stages from the terminal

Bob Shell

Each bob run starts Bob in one mode, does one stage and stops; you run the checker before the next.

The video recreates IBM Bob Shell running the kit headless, sped up while Bob is working. The kit was built for the Bob IDE: headless use was probed with real Bob runs, but no full stage was run end to end. Bob's output is representative; every checker result on screen is real output from the kit's own checkers. It follows a separate run of the kit on the same code, and grey bars cover that run's own IDs and numbers. The figures that tell this story are the ones on this page, and they are illustrative, not a quote.

Chapters

  1. 0:00 Disclosure
  2. 0:07 Claim
  3. 0:17 Map
  4. 0:26 Set up
  5. 1:06 Discover
  6. 2:29 Assess
  7. 3:18 Plan
  8. 4:03 Price
  9. 5:26 Ending
  10. 5:51 Thread card
  11. 6:02 End card
The narration, in full

Disclosure

An illustrative walkthrough of IBM Bob Shell running the kit headless: probed, but no stage was run end to end. Checker results are real; grey bars cover run values.

Claim

A customer asks what it will take to modernise their legacy app, and what it will cost. Four Bob runs turn their code into the documents an SI prices from. We'll follow one bug from the code all the way to a line in the price.

Map

Each bob run starts Bob in one persona mode, does one stage, then stops. You check it before the next.

Set up

First, set up once: open the kit in IBM Bob, trust the folder, check Python and Bob Shell, and set your API key.

IBM Bob opens a new folder in Restricted Mode. Trust it so the kit's hooks can run.

Bob Shell is IBM Bob in your terminal: the bob command. Check you have Python 3.11 or later for the checkers, and that bob answers.

Headless runs need an API key. Set it for this terminal only. Never save it in the repo.

Discover

Discover. You choose a run name, acme-1 here, and Bob records evidence: a file and line for what it read, or a named gap for what it couldn't.

Pipe the command's text to Bob. bob run doesn't expand the kit's slash commands, so read the file, fill in the run name, and pipe it in.

One run per stage, in that stage's mode. --mode picks the persona and --trust the folder. Add --max-cost to cap a run's credits.

Headless runs approve every tool for you. The kit's hooks still run: in our test its guard blocked a write outside runs/.

Bob ends by naming the checks to run. He can't run them: this kit gives Bob no shell.

Evidence points at Desk.java line 93. Each evidence row cites a file and line, or names a gap.

The bug we'll follow: search builds SQL from what a user types. Watch it pass through Assess, Plan and Price.

You run each check yourself. Zero means it passed. Type $LASTEXITCODE after any check to see its exit code.

Assess

Assess. Bob turns the evidence into findings and an assessment report. Because he can't ask you mid-run, you say in the prompt that the last checks passed.

Add the hand-off after the command text. Bob can't ask you mid-run whether the last checks passed, so say so.

That evidence became a finding. Each finding cites its evidence. “Exploit not tested”: Bob says so.

Plan

Plan. Bob orders the work into packages and staffs them from the roster.

The finding became a work package. It carries low, central and high hours. Done when search uses bound parameters.

The plan check passed: the packages have a valid order. A dependency cycle, or an unplaced finding, would fail it.

Price

Price. Bob writes effort rows and a draft statement of work. Your own tools do the money.

The work package is now an effort row, still citing Desk.java line 93. Hours only, under the rate-card role Migration engineer. Bob writes no money.

When a check fails, read what it names. We planted one wording slip; the output is real. Give the error to Bob in a new run.

Fix it with one more run. Put the error in the prompt, in the same mode. Bob changes only what it names.

One line changed. Red is before, green is after, drawn for this video. Bob's run log shows a one-line edit.

Same rules, one line changed: it passes. Its limits stay: whether evidence supports each claim is for your senior reviewer.

Ending

That's the draft pack, ready for senior review.

The SI's engine turns the central hours into the price, not Bob. Synthetic rate; not a quote.

The draft pack is in runs/acme-1/reports/. Ready for senior review, not for a client.

Thread card

One line of code, traced to one line of the price. When the customer asks why the SOW includes this fix, you can show them the line.

End card

To try it: one bob run per stage, a checker after each. Try one stage first: no full stage has been run this way.