10. Enterprise data domain model (customers, orders, invoices, money)
Enterprise data domain with exact money and 64-bit ids
Exercises 0d exact leaves, lossy_integer_literal, cross-field constraints, batch vet, subsume, the schema rendered as TypeScript and Go
Rendered from
use-cases/10-data-model/README.md
in the engine repository. The models, the expected output and the
check.sh that drives the CLI over all of it are in
use-cases/10-data-model/.
Scenario
An order-to-cash domain for a mid-size B2B company: customers with 64-bit upstream ledger ids, orders with line items, invoices with net/tax/gross money, and ISO country and currency codes. The model is used two ways at once:
- Schema. Agent-emitted JSON candidate records (
data/,bad/) are vetted against it in batches: referential integrity included. - Seed-data generator. Evaluating
seed.aonis generating the fixture set: defaults fill in,pack()derives one receivables account per customer, and every constraint has already held over the output or there is no output. - Code source.
xf-domain.aonandxf-order.aonwalk the record types into TypeScript interfaces and Go structs, as a component tree, held against their goldens by the byte gate.
This is the ground-truth-ontology use: one document that is
simultaneously the contract, the checker, the generator and the code source. Money is
the stress test (the reason 0d exact decimals exist), and 64-bit
ids exercise the number tower’s disjoint kinds.
The model tree
seed.aon layers the deterministic seed ledger onto the domain model,
so evaluating it IS the fixture generator. The record bags are
customers, orders, invoices and receivables; pricing is the
exact-money half, and schema the vocabulary, which generates empty
because a type is not data.
$
├── customers
│ ├── cust-1001 (7)
│ └── cust-1002 (7)
├── invoices
│ └── inv-3001 (6)
├── orders
│ ├── ord-7001 (5)
│ └── ord-7002 (5)
├── pricing
│ ├── book (4)
│ └── bundles (2)
├── receivables
│ ├── cust-1001 (2)
│ └── cust-1002 (2)
├── reconcile
│ ├── centsPath 30
│ └── exactPath 0d0.3
└── schema
├── Customer (7)
├── Invoice (6)
├── Order (5)
└── OrderLine (4)
aontu view doc --depth 2 seed.aon draws it, and check.sh pins it
with --out --check. A key with (n) after it is a container the
depth bound stopped at, and n is how many keys are not drawn; a
leaf carries its canon, which is the kind of thing it is rather
than its value.
Model design
domain.aon: record types (close()d maps, optional keys?,re()for ISO codes,neq()to ban XXX/XTS,length()on names,min/maxcents bounds), plus the record bags whose&:spreads apply the types, forceid == keyviakey(), and attachrefer()links (order.customerId,invoice.orderId).seed.aon: includes the domain; an exact price book in0dbigdecimals with pinned sums ((0d0.1 + 0d0.2) & 0d0.3is a theorem, not a comment), per-recordmust()arithmetic checks,match()for price tiers,pack()for the receivables bag,*"open"default order status.exact-money.aon: the money schema stated on the exact leaf (amountEur: bigdecimal & min(0d0)). Only a0dliteral produces abigdecimal, and JSON has no such spelling, so an.aonrecord satisfies this schema and a strictly JSON record cannot.money-wire.aon(the money schema for a JSON wire: a decimal string with a fixed scale, an ISO 4217 currency beside it, and an optional-but-constant conversion mark (dec?: "bigdecimal:2") naming the leaf and the scale.money-convert.aonwrites the crossing point out as theorems) the sign outside the0dprefix, scale absent from the value, the scale-0 point that must still be written, and exact VAT both ways. The convention has its own guide, Carry exact money over JSON.xf-domain.aon: the schema as code:pack(type($.schema), …)walks each record type into TypeScript text, whose field types come frommatch()over the schema’s kinds, and the tree carriesexpected/render/domain.ts, one exported interface per record. The bag is lifted withtype()because a transform over an unmarked bag skips its marked children, and the optional keys are absent from the output: an optional key whose value generates nothing is not a member, so a schema walk cannot seeemail?.xf-order.aon: one model, two targets: the same walk with the two facts it cannot give stated as data (optionalforplacedandstatus,linesas a list ofOrderLine), written toexpected/render/ts/domain.tsandexpected/render/go/domain.go, where the generator spellsledgerIdasLedgerIDwithnom(.n, pascal, $.acronyms)and an optional field as a pointer withomitempty. That spelling is the transform’s now: aontu holds no language knowledge, so the acronym set is written where a reader can see it.reporting.aon: a wider projection;subsumeproves it sound.gaps/: one-file models, each pinning a single behaviour of the arithmetic and constraint families; see below.
Money on records in domain.aon is integer minor units (cents),
one of the two supported spellings; the other is the decimal-string
wire form in money-wire.aon. Exact arithmetic lives in the price
book: (0d0.1 + 0d0.2) & 0d0.3 unifies where binary64 gives
0.30000000000000004, and writing bundle prices as sums pinned to
their expected value makes the price book self-verifying. An exact
number and a binary float do not mix, in either operand order;
gaps/float-mix.aon adds a 0d book price to a float and is refused
with both kinds named:
[aontu/exact_float_mix]: Cannot add value at path $.delta
aontu cannot mix an exact number with a binary float.
Here the operands are bigdecimal and float, in that order.
A plain JSON record carrying 9007199254740993 (2^53+1) is refused
at parse (lossy_integer_literal), even inside vet: an id is never
rounded silently. The 0d spelling stores it exactly, with
biginteger kind, and canon keeps it (0d9007199254740993). The
four numeric leaves are disjoint, so a schema saying
ledgerId: integer refuses that record (bad/id-trap-schema.aon
against data/customer-bigid.aon):
$.customers.ledgerId: constraint [conflict]
[aontu/constraint]: Cannot unify values at path $.customers.ledgerId
expected: integer&min(1)
actual: 0d9007199254740993
number would admit floats, so domain.aon spells the field as the
two-leaf disjunction:
ledgerId: integer & min(1)|biginteger & min(1)
Every schema between the wire and the warehouse spells the id the
same way: reporting.aon leaves ledgerId out of its Customer view,
and bad/reporting-int64.aon, which declares it integer, fails
subsumption.
A link is a tree path (customerId: path("$.customers.cust-1001")),
so domain.aon pins the address shape in the type
(re("^\\$\\.customers\\.cust-")) and attaches refer() at the bag
spread, where the type is applied to each record
(&: $.schema.Order & { id: key(), customerId: refer() }): the
pattern catches a link into the wrong bag, the resolution catches a
link to nothing. pack() derives the receivables bag from the
customer bag with accountFor: refer() & path("$.customers." + key()),
so accounts cannot drift from customers and each link is checked
rather than carried as a loose string.
reporting.aon restates the domain’s *"open" default on status
as *"open" | string. A view declaring plain string changes the
effective default and fails subsumption with
compat_default_changed, so the restatement is what keeps the
projection sound.
The one-file models in gaps/ each evaluate on their own:
agg-sum.aonderives an invoice total from its lines:pick($.lines, amountCents)projects a bag of records to a bag of numbers,sum()folds it withadd(integer cents total to an exact integer), andgreatest()picks the largest line.multiply.aoncomputesmul(2, 1999)and integer-cent VAT asdiv(mul($.amount, 19), 100): multiply first, divide once, anddivtruncates toward zero, so 19% of 3998 cents is 759. Maths arrives as functions; the*character is not an operator, andstar-token.aonshows2 * 1999refused at parse with[aontu/unexpected].unique-by-field.aonputsunique(ledgerId)on the customer bag: no two members may share the named key, and a member without it fails rather than being skipped. Two customers with the same ledger id are refused with[aontu/constraint]at$.customers.spread-cross-field.aonstatesgross = net + taxonce, as amust()in the invoice bag spread. The relative references inside themust()argument do not resolve against each record there, and the file is refused with[aontu/no_path];seed.aonwrites thatmust()on each invoice record, where the same references resolve.list-length-template.aonattacheslength(min(1) & max(50))to a list template. A sizing atom is read from the merged container, and here that is the template list itself, with no members, so the file is refused with[aontu/constraint];domain.aonleaveslines: [&: $.schema.OrderLine]without a cardinality bound.include-alias-spread/is a two-file model:vocab.aondeclares a named alias (T: type(integer & min(1))) and a record type that references it, andmain.aonincludes it with@"./vocab.aon"and adds one record. Named aliases resolve across the include boundary as they do within a file, and the record generates fully unified.
Generated JSON prints exact values as plain digits (0.3,
9007199254740993); a consumer that parses JSON numbers as doubles
rounds them, and --canon keeps the 0d prefix and the exact value.
What check.sh proves
-
aontu seed.aonmatchesexpected/seed.jsonbyte for byte: defaults filled (ord-7002.statusis"open"), everyidequal to its key, and one receivables account per customer. -
aontu model get '$.pricing.bundles' --canon seed.aonmatchesexpected/bundles-canon.txt,{"pro-pair":{"eur":0d69.89,"tier":"premium"},"service-kit":{"eur":0d0.3,"tier":"accessory"}}: canon keeps the0dkind. -
$.reconcile.exactPathis0d0.3in canon and0.3generated:0d0.1 + 0d0.2is exactly0d0.3, and the pin inseed.aonholds. -
One
vetcommand overseed.aonand three data files (two agent-emitted order batches and a0dledger sync) isverdict: valid, exit 0; each data file is vetted separately and the worst verdict wins. The same run reports apref_not_instancecompat finding on the defaultedstatusof the order that omits it; the finding is an advisory lint (severitywarning) and the verdict staysvalid. -
bad/order-dangling.aon, an order whosecustomerIdnames$.customers.cust-9999, is refused with[aontu/refer_unresolved], exit 1, naming the missing customer and the data file, line and column. -
bad/customer-extra-key.json, a customer with an undeclaredsegmentkey, is refused byclose()with[aontu/closed]. -
bad/customer-country.json, whose country is"Switzerland", is refused byre("^[A-Z]{2}$")with[aontu/constraint]. -
bad/customer-id-lossy.json, plain JSON carrying9007199254740993, is refused at parse with[aontu/lossy_integer_literal], exit 1: vet never sees a rounded id. -
bad/id-trap-schema.aon, which declaresledgerId: integer & min(1), refusesdata/customer-bigid.aonwith[aontu/constraint], citinginteger&min(1)against0d9007199254740993. The domain’sinteger | bigintegerdisjunction admits the same record (it is part of the batch in 4), andaontu model get '$.customers.cust-1003.ledgerId' --canonprints0d9007199254740993. -
exact-money.aonacceptsdata/quote-exact.aon(0d10.50) and refusesdata/quote-float.json(10.5) with[aontu/constraint]againstbigdecimal:$ aontu vet exact-money.aon data/quote-float.json verdict: invalid $.quote.amountEur: constraint [conflict] [aontu/constraint]: Cannot unify values at path $.quote.amountEur expected: bigdecimal&min(0d0) actual: 10.5 -
data/quote-0d.json, which spells the amount0d10.50, vets as valid, becausevetparses.jsondata files with the aontu parser, and a strict JSON parser rejects the same file. Both halves are asserted. -
aontu vet --at '$.schema.Customer' domain.aon data/customer-record.jsonvalidates one bare record against one named type:verdict: valid. -
aontu subsume reporting.aon domain.aonisverdict: subsumes, exit 0: every document the domain admits, the reporting view admits. -
aontu subsume bad/reporting-int64.aon domain.aonisundecided, exit 3, citingsub_disjunct_distributionand the domain’sbiginteger&min(1)alternative that the view’sintegerdoes not admit. -
gaps/agg-sum.aonevaluates with"total": 4008and"largest": 3998. -
gaps/multiply.aonevaluates with"amount": 3998and"vatCents": 759. -
gaps/star-token.aonis refused at parse with[aontu/unexpected], exit 1. -
gaps/float-mix.aonis refused with[aontu/exact_float_mix], exit 1. -
gaps/spread-cross-field.aonis refused with[aontu/no_path], exit 1. -
gaps/list-length-template.aonis refused with[aontu/constraint], exit 1. -
gaps/unique-by-field.aonis refused with[aontu/constraint]at$.customers, exit 1. -
gaps/include-alias-spread/main.aongenerates the record through the include,"ledgerId": 5and"id": "cust-1001", exit 0. -
data/quote-wire.jsonis strict JSON (JSON.parseaccepts it), andaontu vet money-wire.aon data/quote-wire.jsonisverdict: valid. -
The conversion mark is optional to send and impossible to contradict:
data/quote-wire-marked.json, which echoes"dec": "bigdecimal:2"beside a negative amount, is valid;bad/quote-wire-mark.json, which claims"dec": "float", is refused at$.quote.dec, citingbigdecimal:2. -
The wrong scale (
"3998.1") and a bare JSON number (3998.19) are both refused at$.quote.amount. -
aontu jsonschema --at '$.Money' money-wire.aoncarries the pattern^-?(0|[1-9][0-9]*)[.][0-9]{2}$and"const": "bigdecimal:2", anddecis not inrequired; running the exportedtypeandpatternover the same four records gives the verdicts vet gave. -
aontu --canon money-convert.aonprints"amount":0d3998.19,"refund":-0d12.05,"sameNumber":0d10.5,"scaleZeroRight":0d10.0and"vatExact":0d759.6561: the conversion, its sign, its scale and its VAT all pin. -
The two transforms write their goldens byte for byte through jostraca:
xf-domain.aonanswersexpected/render/domain.ts, one exported interface per record, andxf-order.aonanswersts/domain.tsandgo/domain.go, where the Go text spellsledgerIdasLedgerID,linesas[]OrderLineand an optional field as*stringwithomitempty. -
Both ports build the same trees for both transforms, byte for byte.
Running it
From this directory, ./check.sh runs all 32 assertions and exits 0.
It drives the TypeScript CLI (ts/bin/aontu.js, or the command in
$AONTU), and every refusal is asserted by exit code and machine code
rather than by error prose. Three verbs by hand:
aontu seed.aon # generate the fixture set
aontu vet seed.aon data/order-batch-1.aon data/order-batch-2.aon data/customer-bigid.aon # vet a batch
aontu model get out xf-order.aon # the schema as TypeScript and Go