This week I’ve been building bundles that pair a JSON Schema with OpenLint-format rules, after finding out that the decoder only pours part of the bedrock. When I looked at payments and banking, I said constrained output moves the remaining errors from formatting into meaning. Payments turns out to be the clearest case of what’s left once the format is solved, because so many of the fields have to agree with each other.
The answer I picked
Turn an approved invoice into a credit transfer instruction shaped like an ISO 20022 pain.001. Last time I couldn’t find a JSON Schema of the message that a decoder could use directly, and that hasn’t changed, so I had to make one. I want to be clear about what it is: the element names are ISO 20022’s, as they appear in published bank guides and in the contracts of banks like Citi, but the JSON binding is mine. I flattened some of the nesting to fit the page. You’d map this onto the real XML before it went anywhere, and a bank’s own implementation guide would have the last word.
bundle:
id: payments-credit-transfer
version: 0.1.0
status: illustrative # ISO 20022 element names in a JSON binding of ours, not a registered schema
answers: Turn an approved invoice into a pain.001-style credit transfer instruction
derived_from:
- ISO 20022 pain.001 element names, as used in published bank contracts and guides
- https://developer.gs.com/docs/services/transaction-banking/cbpr+-pain001/
engine:
runtime: deepinfer
tested_against: {xgrammar: 0.2.2, strict_mode: true}
so_args: {schema_type: json}
sampling: {temperature: 0, max_tokens: 2048, seq_seed: 20261106}
env: {DEEPINFER_BATCH_INVARIANT: 1, DEEPINFER_ENABLE_MTP_VARLEN: 0}
input:
context: [invoice, debtorName, debtorIban, currencies, today]
rules:
debtor-account-valid:
description: Check our own account before spending any inference
given: $.context.debtorIban
severity: error
then: {function: ibanMod97}
invoice-attached:
description: An instruction needs a source document
given: $.context
severity: error
then: {field: invoice, function: truthy}
shape:
type: object
additionalProperties: false
required: [GrpHdr, PmtInf]
properties:
GrpHdr:
type: object
additionalProperties: false
required: [MsgId, NbOfTxs, CtrlSum, InitgPty]
properties:
MsgId: {type: string, minLength: 1, maxLength: 35}
NbOfTxs: {type: string, pattern: "^[0-9]{1,15}$"}
CtrlSum: {type: string, pattern: "^[0-9]{1,12}\\.[0-9]{2}$"}
InitgPty:
type: object
additionalProperties: false
required: [Nm]
properties:
Nm: {const: "${context.debtorName}"}
PmtInf:
type: object
additionalProperties: false
required: [ReqdExctnDt, DbtrAcct, CdtTrfTxInf]
properties:
ReqdExctnDt: {type: string, format: date}
DbtrAcct: {const: "${context.debtorIban}"}
CdtTrfTxInf:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required: [EndToEndId, InstdAmt, Ccy, CdtrNm, CdtrIBAN]
properties:
EndToEndId: {type: string, minLength: 1, maxLength: 35}
InstdAmt: {type: string, pattern: "^[0-9]{1,12}\\.[0-9]{2}$"}
Ccy: {enum: "${context.currencies}"}
CdtrNm: {type: string, minLength: 1, maxLength: 140}
CdtrIBAN: {type: string, pattern: "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$"}
output:
rules:
control-sum-matches:
description: CtrlSum must equal the sum of the instructed amounts
given: $
severity: error
then:
function: sumEquals
functionOptions: {items: answer.PmtInf.CdtTrfTxInf, field: InstdAmt, total: answer.GrpHdr.CtrlSum}
transaction-count-matches:
description: NbOfTxs must equal the number of transactions
given: $
severity: error
then:
function: countEquals
functionOptions: {items: answer.PmtInf.CdtTrfTxInf, count: answer.GrpHdr.NbOfTxs}
creditor-iban-checks-out:
description: The pattern checks the form, this checks the check digits
given: $.answer.PmtInf.CdtTrfTxInf[*].CdtrIBAN
severity: error
then: {function: ibanMod97}
no-duplicate-transactions:
description: The decoder does not enforce uniqueItems, so this rule catches exact repeats
given: $.answer.PmtInf
severity: error
then:
function: schema
functionOptions:
schema:
type: object
properties:
CdtTrfTxInf: {type: array, uniqueItems: true}
not-in-the-past:
description: A payment cannot be executed before today
given: $
severity: error
then:
function: notAfter
functionOptions: {earlier: context.today, later: answer.PmtInf.ReqdExctnDt}
on_failure:
error: {retry: 1, change: [seq_seed, append_rule_messages_to_prompt], then: send_to_human}
always: {action: human_releases_payment}
Where the design choices are
Money is a string. InstdAmt is "1250.00", enforced by a pattern, not a JSON number. A decoder happily emits 1250.1000000000001 if you let it, and floating point has no business near a payment. It also means my functions add in integer minor units instead of summing floats. Note that this two-decimal pattern is itself a simplification, because not every currency has two decimal places, and a real bundle would have to tie the pattern to the currency.
The grammar can’t do arithmetic. This is the heart of it. NbOfTxs has to equal the number of transactions and CtrlSum has to equal the sum of the amounts, and guides warn that a bank may reject the file when they don’t. These are the exact facts a token-at-a-time grammar can’t hold. They can only live in rules.
A pattern checks the form, and the check digits need a function. The IBAN pattern lets through anything IBAN-shaped. creditor-iban-checks-out runs the actual mod-97 check. A model transposing two digits while copying an account number from a PDF is exactly the mistake I’d worry about, and the decoder can’t see it.
uniqueItems is here on purpose. I said the decoder doesn’t enforce it, and my shape lint would fail if it appeared in the shape. So it moves to no-duplicate-transactions, where the linter’s schema function enforces it after generation. This is what it looks like when a keyword moves out of the bedrock and into the landscaping. It only catches exact repeats, though. Two transactions sharing an EndToEndId with different amounts would pass, and a real bundle would need a function comparing just that field.
The debtor is bound. Who’s paying isn’t a question for a model. The debtor’s name and account come in as const values, and the currencies this business has approved become the enum.
How it runs as part of inference
- Input rules run first, including the mod-97 check on our own debtor account. It’s cheap to find out before spending anything on inference that the account on file is wrong.
- The shape is bound and compiled, in 144 milliseconds on my laptop against DeepSeek’s real tokenizer.
- The decoder generates. It can’t emit an unapproved currency or a number where a string belongs.
- Output rules run on the envelope, comparing the amounts, counting the transactions, and checking the dates against today.
- On failure, it retries once, then goes to a person. Fewer retries than the other bundles, on purpose. A model that gets the arithmetic wrong twice on a payment shouldn’t get a third try.
- A person releases the payment. No bundle changes that.
What the fixtures showed
These are hand-written documents testing each path, not model output.
- A clean single-line instruction passed everything.
- A broken one was accepted by the decoder: two transactions under a header that says one, a control sum for one, an exact duplicate transaction, a creditor IBAN with one wrong digit, and an execution date in the past. The rules flagged all five.
- A Bitcoin ticker in the currency field, and a bare number in
CtrlSum, were refused by the decoder. - A debtor account failing its own check digits and a missing invoice were stopped by the input rules.
The second result is the one worth sitting with. It’s a document the decoder considers valid, and a bank’s systems would bounce it, or worse, not bounce all of it. One wrong IBAN digit is usually caught by the check. A correct IBAN that belongs to someone else isn’t caught by anything here.
What’s still missing
That last sentence is the real limit. These rules prove that the instruction is internally consistent. They don’t prove the creditor is who the invoice says, that the invoice is genuine, or that it should be paid. A supplier who changes their bank details by email is a fraud pattern, and a model extracting those new details perfectly and consistently makes it worse. Checks like confirmation of payee, the vendor master and sanctions screening sit outside of this file and need to stay there.
My first post also described a dispute shape and a remittance one, and I left both out. Disputes would need the card network reason codes I couldn’t source publicly last time, and I still won’t guess at them. Remittance matching would need the bank statement as context, which is a much larger envelope. Also, ReqdExctnDt being today or later says nothing about cutoff times or bank holidays, and those are the checks a real payment file fails on.
Payments has had an agreed shape for decades. What a bundle adds is a place to write down the relationships inside that shape, the ones that have always been in bank guides as prose. Those were going to be checked by somebody anyway. A model in the loop just means they have to be checked by something that doesn’t get tired.
