Story: Let the plan state domain rows, not a bag
Table of Contents
This page documents a story in Sprint 29. It captures the goal, current status, acceptance criteria, and the tasks that compose it.
1. Goal
A trade has one description, in the domain. The planner draws a product and constructs the domain's own rows; the writer persists them. The per-asset-class *_terms bag disappears, and a product can no longer be assembled from whichever fields happen to be present.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent sprint | Sprint 29 |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-10-30 |
3. Acceptance
- The plan carries no
*_termsbag: the slot states domain rows, and the writer persists them. Each planner keeps a struct of what it draws, which is the generator's own shape and not what a slot carries. - Every product the planners draw is constructed by a named constructor that states what that product is, and that constructor cannot fill a column the product does not have. Which products a population draws is a coverage question and lives in Populate the ACME books with a realistic trade population.
- The writer persists and no longer translates.
- The families that book today still book, proved by a provisioning run and not by a unit test alone.
4. Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
| Scaffold story: Let the plan state domain rows, not a bag | DONE | 2026-10-28 | 2026-10-28 | Story scaffolding rides this task: documents, sprint wiring, and the scaffold PR. Close it before merging that PR. |
| Construct one product properly | DONE | 2026-10-28 | 2026-10-28 | Prove the story's shape on one product before five families move. Add a named constructor for EquityCliquetOption beside the equity option instrument type, make the writer build that product through it, and show that a frequency cannot be stated on a product that has none. |
| Construct every commodity product by shape | DONE | 2026-10-28 | The commodity writer copies every field of the bag into one row for all twenty-five codes, and the table has no cross-column check to stop a forward carrying a barrier. Give each code a named constructor whose terms hold only the columns its shape states, so a mixture cannot be assembled. | |
| Give the commodity products their own facts where the family can state them | DONE | 2026-10-28 | 2026-10-29 | The commodity constructors fixed each product's shape, but several products are drawn as a plainer shape than they are: the Asian option states no averaging, and the basket products state no members even though the family has a constituent table nothing writes to. Give those products their own facts, and record the ones the family still cannot state. |
| Construct every bond product by name | ABANDONED | 2026-10-29 | The bond writer assembles one table's rows from the terms bag for ten routed codes. Give each code a constructor that states its own facts, so the bond family joins equity and commodities in constructing rather than assembling, and the bag has one fewer family to carry. | |
| Construct every rate product by name | DONE | 2026-10-29 | 2026-10-29 | Eight rate products are drawn from one terms bag and translated into a header row plus fact rows by a writer arm several hundred lines long. Give each product a constructor that states its own facts, and let the writer hand over what it is given. |
| Construct every fx product by name | DONE | 2026-10-29 | 2026-10-29 | The fx writer is the last family that assembles products from the terms bag, across six instrument tables and sixteen routed codes. Give each drawn code a constructor, so every family bookable today constructs rather than assembles and the bag has one family left. |
| State what a credit reference entity is | DONE | 2026-10-29 | 2026-10-29 | The credit writer and its routing exist and the credit catalogue is seeded, but no ACME party has a credit generation config, so a provisioned tenant holds no credit trades at all. Measured on a recreated database: zero rows in the credit instrument table, and the demo profile seeds bond, commodity, equity, fx and interest_rates only. Unlike commodities, which were chartered in the same sprint, credit has neither a book nor a config. |
| Flip bond onto its rows | DONE | 2026-10-29 | The first family to stop carrying a bag. The slot gains an instrument batch, the bond planner constructs its instrument, and the writer persists it. The security stays in the bag until the next slice, because the run writes the issue before it books the trade. | |
| Flip commodity onto its rows | DONE | 2026-10-30 | 2026-10-30 | Twenty-five commodity codes construct today, but the planner still fills a bag and the writer translates it. Move that translation into the planner so the plan carries the rows, and the writer only persists them. |
| Flip rates onto its rows | DONE | 2026-10-30 | 2026-10-30 | Ten rate codes construct today, but the planner still fills a bag and the writer translates it. Move that translation into the planner so the plan carries the rows, and the writer only persists them. |
| Flip fx onto its rows | DONE | 2026-10-30 | 2026-10-30 | Sixteen fx codes construct today, but the planner still fills a bag and the writer translates it. Move that translation into the planner so the plan carries the rows, and the writer only persists them. |
| Flip equity onto its rows | DONE | 2026-10-30 | 2026-10-30 | Seventeen equity codes construct today, but the planner still fills a bag and the writer translates it. Move that translation into the planner so the plan carries the rows, and the writer only persists them. |
| Flip credit onto its rows | DONE | 2026-10-30 | 2026-10-30 | Six credit products construct today, but the planner still fills a bag and the writer translates it. Move that translation into the planner so the plan carries the row, and the writer only persists it. |
| Flip the bond security | DONE | 2026-10-30 | 2026-10-30 | The bond instrument states its own row now, but the security still travels in the bag: ensure_issue builds a bond_issue composite, its legs, amounts and rates from bond_terms before the trade is booked. Move that construction to the planner so the bag carries nothing for bonds and the family can be deleted from it. |
5. Decisions
- Constructors beside the types, not in the synthetic component. The couplings are facts about the products, so they live with the products. The synthetic component then depends on the trading domain the way any producer does, which it already does through the writer.
- One family at a time. Each family's move deletes that family's bag and proves itself with a run, so there is never a state where nothing books.
- The config and the scope stay. They are the generator's own language and they do not describe trades.
- The drawing struct stays, under the family's name.
trade_termsand the slot'stermsare deleted, so the plan states rows and nothing else. Each planner still holds a struct of what it draws; that is the generator's vocabulary, it never reaches a slot, and its comment says so. - A slot that states rows holds no bag at all. The bag's variant defaulted to
its first alternative, which was a bond, so a family that stopped filling it
read as a bond with empty terms and the run stopped at the bond issue table.
The variant held
std::monostatefirst while any bag remained, and then the variant itself was deleted with the last one. - A flip is a move, not a redesign. Each family books the same rows after it flips, and the provisioning run is the proof: a changed count is a defect in the move rather than a decision.
- Which products a planner draws is a population question. A family that draws one of its ten codes still cannot produce the other nine, but that is what a population covers, not what a plan states, so it is tracked in the population story.
6. Out of scope
- The trading model's own normalisation, where several products share a table and a check constraint says which columns each may fill. That stays as the backstop; this story stops a generator from having to guess at it.
7. Why
The synthetic population carries each trade's values in a per-asset-class bag.
trade_terms is a std::variant of bond_terms, commodity_terms,
rate_terms, fx_terms, equity_terms and now credit_terms, and
equity_terms alone is about forty members. A planner fills the bag and a
writer reads it and constructs the trading domain's rows from it.
The cost is measured. In one family the bag let eight values through that the schema refuses, and each one was found by a provisioning run: a target on an accumulator, an option type on a touch, a forward with no quantity, a swap returning a value nobody defined, a kind that named a classification rather than a vocabulary. None of them was visible by reading the schema, because the invariant is not in the schema a reader reads; it is in the check constraints.
8. What the bag is
Three kinds of thing mixed together:
| Kind | Examples | Owner |
|---|---|---|
| The generator's own facts | book, counterparty, trader, trade date, netting set, trade id | the generator |
| Drawing parameters | seed, tenor band, notional band, which catalogue | the generator |
| The product's economic content | strike, expiry, barrier, basket | the domain |
Only the first two are the generator's language. The third belongs to the domain, and the bag is a second, weaker description of it.
It is not an anti-corruption layer: an ACL translates once, at a boundary, in one direction, against a contract a test can hold still. This bag is interpreted independently by both ends, is per asset class rather than per product, and leaves the product's identity as a string that the writer branches on.
9. The defect underneath
The trading model states a product's identity as data: a trade_type_code
column plus check constraints naming the columns each code may fill. That is a
legitimate storage normalisation – two products whose facts coincide share a
table – but it puts half of a product's type in SQL, where a generator cannot
see it.
So this story is not only about the bag. It is about giving a product one description, in the domain, and a way to construct it that cannot state an illegal combination.
10. The model
| Part | Owner | What it is |
|---|---|---|
trade_generation_config |
generator | A specification: asset class, book, bands, seed, counts. |
| the scope | generator | The resolved drawing context: party, books, counterparties, catalogues, as-of date, seed. |
| the population | domain | A list of trades: a trade envelope and its instrument batch. |
The planner draws the product and constructs the domain rows; the writer persists them. The generator keeps specification and drawing and stops describing products.
The couplings move into named constructors beside the types, one per routed product, each taking that product's own facts and filling exactly the columns it states. The rule becomes: a product is constructed, never assembled. The SQL check stays as the backstop it should always have been.
11. The flip: what it changes, and the order
Every family now constructs its products, but the population still carries each trade as a bag of drawn fields and the writer still translates one. The last step removes both: a slot states the domain rows, and the writer persists them.
1. The slot carries an instrument batch. trade_population_slot.terms is a
std::variant of eight bags; it becomes one instrument_batch, the shape the
booking request already takes. The envelope the slot already carries – book,
counterparty, trader, trade date, structure – is untouched, because it belongs
to the trade and not to the product.
2. Construction moves into the planner. A planner already resolves the scope; it will call the constructors the last four tasks produced and put the rows in the batch. A planner that cannot construct a product will fail where the drawing happens, with the product named, instead of failing in the writer after the trade has been half-built.
3. The writer stops translating. Its per-family arms go. It stamps the audit columns on every row in the batch, builds the booking request's anchor and booking from the envelope, and sends. Stamping becomes one walk over the batch rather than six hand-written ones.
4. The bag dies last, and only when empty. trade_terms and the eight
*_terms structs are deleted when the last family stops filling them. Until
then they stay and one family uses each, which is what makes the move safe: a
family that has flipped cannot regress, and a family that has not keeps booking.
12. Order of families
Bond, commodity, rates, fx, equity, credit. Smallest first, and bond first because it draws one product, so the seam is proved where there is least to change. Each family flips in its own task with its own provisioning run, and the population it books must match what it booked before the flip: the run is the proof, and a changed count is a defect in the move rather than a decision.
13. What must not change
- The rows the population writes. A flip is a move, not a redesign; the eight families book the same trades afterwards.
- The envelope. Book, counterparty, trader and dates stay the generator's.
- The config and the scope. They are the generator's own language.
14. What this does not fix
Products the planner does not draw. Bonds draw one of ten, and their fact rows are empty; credit draws five of six. Those are population decisions recorded as their own tasks, and the flip does not hide them: a family that draws one product still books one product.
15. Design record
The reasoning is written up in What a trade population is made of, which states the three kinds of thing, the defect underneath and the order of work.
16. Result
The plan states rows and the bag is gone. Every family that books constructs its
products through named constructors, the planner puts the rows in the batch, and
the writer persists what it is given: no family translates a bag any more, and
trade_terms, the slot's terms and the writer's six bag arms were deleted
with the last of them.
Six families flipped, in the order the story set — bond, commodity, rates, fx, equity, credit — and the bond security followed, which is what made the bag removable. Each flip was proved by a provisioning run against a fresh tenant, and every one booked the population it booked before its flip: 24 bonds, 25 commodity rows and 12 constituents, 24 rate headers with their legs, amounts, rates and strikes, 32 fx rows with 4 range bounds, 17 equity rows with their children, and 5 credit rows. The tenant holds 127 trades after every run.
Two defects surfaced that were not the story's own.
- A slot that filled no bag read as a bond, because the bag's variant defaulted
to its first alternative. Commodity flipped first and the run died on the bond
issue table with a nil id. The variant held
std::monostatefirst until the bag was deleted. maincould not provision any tenant: the catalogues work had removed the registrar entry for the legal entity publish route while the model still declared it, so the subject had no handler and every run stalled for twenty minutes. Fixed in #4092, without which no flip could be proved.
What this story did not do: it did not widen coverage. Bond still draws one of its ten codes, the rate planner never reaches its knock-out swap, the fx planner never reaches its variance swap or accumulator, and a swaption's drawn strike is dropped by its constructor. Those are population questions and are tracked there, in Populate the ACME books with a realistic trade population.