Task: Sync the ores.trading tree to its models

Table of Contents

This page documents a task in the Clean ores.trading to the component clean standard story. It captures the goal, current status, acceptance, and any notes or results.

1. Goal

Make the checked-in ores.trading tree equal to what its models generate, and make the component build and pass its suites at that tree. This is item group G plus the protocol items P01, P02, P03 and P05, and the wiring item W01. It changes no model and no data shape: it lands work the generator already produces and deletes what the generator supersedes.

The measured baseline is 1636 files that would change and 115 that would be created. Of the changes, 1434 are the generated-file banner alone, 78 are the eventing rename, 123 are substantive template changes and 1 is the root CMakeLists.txt. A component usually cannot be cleaned by landing that much at once, but here it is safe, because 88 per cent of the changes are attributable to one cause and the generator already reproduces the tree's semantics.

2. Status

Field Value
State DONE
Parent story Clean ores.trading to the component clean standard
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-09-26

3. Acceptance

  • check_component_drift.py --component trading-cpp --dry-run reports nothing to change and nothing to create, and a second run reports the same.
  • The whole tree builds, and ores.trading.api.tests, ores.trading.core.tests and ores.trading.service.tests pass.
  • The 21 *_publishes_nats_changed_event failures are gone, because the 114 trading notify triggers emit the canonical payload.
  • regenerate_cmake_component_files.py --check passes for ores.trading.api, ores.trading.core and ores.trading.service.
  • Decision D1 is applied: the nine rates families are served by the generated per-family handlers and registrars, and the hand consolidation is deleted. No subject is registered twice.
  • Every generated file carries the marker, and every hand-written file the generation supersedes is deleted.
  • The consumers in ores.shell, ores.ore and ores.reporting are migrated in the same PR, and nothing is carried for backwards compatibility.
  • W01 is closed by reading the composition points, not by trusting the drift gate.

4. Plan

4.1. Re-measure and confirm the cause of every block

Run the dry run and confirm the classification at the new head before writing anything.

projects/ores.codegen/venv/bin/python projects/ores.codegen/scripts/check_component_drift.py --component trading-cpp --dry-run

The classification to expect: banner only, eventing rename, the trigger template, and the creates. If a block appears under a cause the PR 1 baseline does not have, stop and explain it before regenerating.

Measured at 39f99acef3: 1636 would change and 115 would be created, unchanged from PR 1. The record is evidence/pr2_drift_baseline.txt.

4.2. What the survey before regeneration found

instrument_protocol.hpp holds two groups, and only one can go here.

  • The nine rates families. Their structs go, because the generated per-family headers replace them.
  • Four families with no model at all: commodity, composite, credit and scripted. Their messages live only in this header, so deleting the whole file would break their handlers and their shell units. They stay until PR 3 models them, and the header disappears when the last one moves.

Consumers of the nine families' consolidated request types outside the trading component, all of which this PR migrates:

  • Nine hand-written shell units under projects/ores.shell/application/src/app/commands/trading/, one for each rates family.
  • projects/ores.ore/service/src/messaging/ore_import_execute_handler.cpp.

The shell has two trading command trees. The generated one is projects/ores.shell/trading/ and the regeneration rewrites 66 of its files. The rates units and the four unmodelled families' units are hand-written under ores.shell/application/. Check both when the protocol moves.

registrar_rates.cpp registers the nine families against rates_instrument_handler.hpp. Both are deleted, and the generated per-family registrars are wired at the same composition point. Read the composition point before deleting, because the drift gate cannot see an unwired registrar.

4.3. Apply D1 before regenerating, not after

The generator emits per-family protocols, handlers and registrars for nine rates families: fra, vanilla swap, cap floor, swaption, balance guaranteed swap, callable swap, knock out swap, inflation swap and rpa. The tree consolidated those nine by hand into instrument_protocol.hpp plus rates_instrument_handler.hpp, registered from registrar_rates.cpp. Regenerating while the consolidation remains would subscribe the same subjects twice.

The work: let the nine per-family files be created, then delete the per-family sections of instrument_protocol.hpp, delete rates_instrument_handler.hpp, and rewrite registrar_rates.cpp to call the nine generated registrar functions, exactly as registrar_equity.cpp calls the equity ones. The family master stays, because it is the composition point: registrar.cpp calls detail::register_rates_handlers, and the other families keep the same shape.

4.4. Where the work stands after the first regeneration

The component was regenerated at 39f99acef3: 1636 files changed, 115 were created, and the run reports untracked files because none of the creates is committed yet. That report is expected, not a defect.

The D1 edits are applied on the working tree, uncommitted:

  • instrument_protocol.hpp keeps the four families that have no model (commodity, composite with its legs, credit, scripted), 34 structs, and loses the nine rates families and the 17 now-unused domain includes.
  • rates_instrument_handler.hpp is deleted.
  • registrar_rates.cpp is rewritten to call the nine generated register_<family>_instrument_handlers functions.

4.5. The consumer migration the wire rename forces

The generated canonical protocol does not keep the consolidated struct names or subjects. Per family, the mapping is:

Consolidated Generated Subject
get_<family>_instruments_request/response list_<family>_instruments_request/response trading.v1.<family>_instruments.list, unchanged
save_<family>_instrument_request/response put_<family>_instrument_request/response ...save becomes ...put
delete_<family>_instrument_request/response unchanged unchanged
get_<family>_instrument_history_request/response list_<family>_instrument_versions_request/response ...history becomes ..._versions.list

The families are fra, vanilla swap, cap floor, swaption, balance guaranteed swap, callable swap, knock out swap, inflation swap and rpa. The request and response fields differ from the consolidated ones as well, so each call site is read against the generated struct rather than renamed mechanically.

The call sites are the nine hand-written shell units under projects/ores.shell/application/src/app/commands/trading/ and projects/ores.ore/service/src/messaging/ore_import_execute_handler.cpp. The shell units also carry the subject strings as literals, so the migration replaces them with the generated constants, which is item P02.

Still to do after the migration: regenerate the CMake source lists, because rates_instrument_handler.hpp is gone; build the whole tree; run the four suites; then run the dry run twice for idempotence.

4.6. The first generator defect the build found

The regenerated component does not compile, and the defect is in the generator, not in the model. evidence/pr2_generator_defect_equality.txt holds the error and the templates.

cpp_domain_type_class.hpp.mustache emits a defaulted operator= = for every domain class. cpp_field_group.hpp.mustache emits no comparison for the field-group struct it generates. A domain class whose member is a field group therefore has its comparison implicitly deleted, and the build treats that as an error under -Werror. At 39f99acef3 neither file had a comparison, so the committed tree compiled; the current templates add one to the domain class and not to the field group.

Every trading entity with a field-group member is affected, which is trade and the entities that inherit instrument_identity. This is item G05 work: fix the field-group template at its source in the org source that tangles to cpp_field_group.hpp.mustache, add a regression test to the codegen suite, then regenerate. Item G07 applies while the defect is open: do not refresh any other artefact that the same generator feeds.

The other field-group models are one in ores.dq and one worked example in ores.codegen. Neither component is in COMPONENTS_UNDER_TEST, so no registered component changes, but ores.dq's generated output does and that is recorded here.

4.7. The second generator defect: the repository ignores the group pair

The API library builds after the equality fix. The core library stops on the 26 instrument repositories, and the defect is in the repository template.

Two grammar forms exist for a grouped entity. trade uses the modern ** Domain groups table, which replaces the struct body entirely. The 26 instrument entities use the older pair, :domain_identity_group: and :domain_audit_group:, which nests those two groups and leaves the entity's own columns flat. The domain-class template and the mapper template honour the pair; the repository template does not, so it emits v.instrument_id where the domain type has v.identity.instrument_id. The committed tree compiled because that repository was hand-written; the regeneration replaces it.

Evidence: evidence/pr2_generator_defect_repository_group.txt.

Two ways forward, and the choice is a model-shape decision:

  • Teach the repository template the pair, addressing the grouped columns through their member. This keeps the current model shape and the wire format, and it is the smaller change.
  • Migrate the 26 models to the ** Domain groups table. The loader then requires every column to be in a group, so each entity needs a third field group for its own columns, the struct body changes from "two nested members plus flat columns" to a member list, and the wire JSON changes shape for 26 instrument types. It also needs the loader to resolve the shared ores.dq.audit_record group across components; the table form resolves a group only beside the entity's own model today, which is a second small defect the experiment confirmed.

Recorded recommendation: take the first now, because the component's acceptance is drift-clean regeneration, not a wire reshape, and revisit the table form when PR 3 models the remaining families.

4.8. The group prefix reaches further than the repository

The repository template is fixed, and the repository layer builds. The service layer stops at the same defect in its own template: key_from reads v.instrument_id with no member, the version literal has no scaffold prefix, change_commentary is an audit member read flat, and a compound key's integer member is passed to a parameter that takes text. Evidence: evidence/pr2_generator_defect_service_group.txt.

The template states its conversions inline, as {{#is_uuid}} arms, at six sites and has no arm for a number. The durable fix is to prepare those expressions in core.py as string fields and have the template splice them, the way it already splices primary_key.v_args and removal_key_args. That deletes the inline arms instead of adding a fourth type to each one.

The same class of site may exist in the service header, the table I/O, the JSON I/O, the generator and the history-difference payloads. Expect the build to name them one at a time; each is the same prefix or the same text conversion.

4.9. The enum write field

With the prefixes and the key text in place the services compile, and one error is left: to_domain assigns a text wire field to the domain's enum member.

v.classification.product_type = write.product_type;

The model states both sides. The column is text (:cpp_type: std::string, :type: product_type_t) and the field group that carries it declares domain::product_type, so the domain is an enumeration while the wire is text. The conversion between them is what is missing, and the domain header already provides it as product_type_from_string. Evidence: evidence/pr2_generator_defect_enum_write.txt.

The fix is an arm in both to_domain functions for a write field whose column states :is_enum: true, with a regression test, because every enum column in every component needs the same arm.

4.10. Where the build stands after the enum arm

The enum arm is in, and the whole ores.trading component compiles: the API library, the core library, the service library and the executables. Two further blockers were cleared on the way.

  • The generated shell command units take a product type as an argument and the shell's token reader had no conversion for it, so the build stopped on its own static assert. product_type now states its own parse_token, which the reader finds by argument-dependent lookup, as ores.synthetic's binding_mode and scope do.
  • The six component_files.cmake lists were stale, so the nine generated registrar sources were never compiled and the service library linked with undefined handler registrars. Item G06.

4.11. The consumer migration the tree build now reaches

The tree build stops at 67 per cent in ores.ore's hand-written ore_import_execute_handler.cpp. It speaks the old consolidated protocol: 26 using declarations name old request types and 192 lines reach through req.data..

This is a rename and a reshape, not a rename alone. The generated write request carries the domain object under the entity's own member rather than under data, and it carries a change intent:

save_bond_leg_amount_request req; becomes req.data.amount_role = …; put_bond_leg_amount_request req; req.bond_leg_amount.amount_role = …;

The read side is a rename with the paged fields to check: an old get_bond_issues_request with offset and limit becomes list_bond_issues_request, whose fields are the model's, not the consolidated header's.

Every call site is read against the generated struct rather than renamed mechanically, and the nine shell units under ores.shell/application/ need the same pass.

evidence/pr2_consumer_migration_recipe.txt states the mapping in full: the four request shapes, the canonical construction the generated shell units use, the line-by-line mapping, and the counts for the handler. Two things a rename cannot carry: a whole-object assignment expands to one assignment per member of the generated <x>_write, whose member list the protocol header states; and every response check reads result.outcome rather than a success bool. The handler's nats_call helper needs no change, because it is templated on typename Req::response_type.

4.12. The codemod, and the two defects left in it

projects/ores.codegen/scripts/migrate_consumer_to_generated_protocol.py applies the mapping in one linear pass. Its dry run on the handler reports 81 declarations renamed, 211 field accesses, 34 whole-object sites and 59 response checks, and it leaves the four families with no model on the consolidated request they have today.

Two defects are known and unfixed, both found by building the migrated handler:

  • The domain path for an identity-group member is not always resolved, so an expansion reads obj.instrument_id where the domain type has obj.identity.instrument_id. The group-header parse in domain_paths is what needs the fix.
  • An expression that dereferences a smart pointer is appended to without parentheses, so *data.option becomes *data.option.instrument_id. The expansion must parenthesise the expression before appending the member.

The handler was reverted after the trial, so the codemod is committed and the migration is one run away once those two are fixed.

Both defects are fixed: a nested group member is resolved by its type rather than by its name, and an expression that is not a plain member path is parenthesised before the member is appended. The trial then reported four further consequences, all of them in the codemod or the include list rather than judgment calls: evidence/pr2_migration_remaining_work.txt. The one place a person decides is the container save, which must choose between the single-row request and the batch one. The handler was reverted again, so the tree stays coherent until all four are fixed.

4.13. The seventh trial: the handler migrates, and the rollback key is renamed

The codemod's enum arm tested ("string" not in wire_type), which is false exactly when the wire member is text, so the conversion never fired. The correction inverts the test: a domain member that names an enumeration and reaches a wire member that is text converts with ores::trading::domain::to_string. It is one line in the codemod, and it is the defect the fifth trial left open.

The run over the handler reports 81 declarations renamed, 211 field accesses, 34 whole-object sites and 60 response checks. The documented post-steps then apply: the nine rates includes, the container save as a put_many_trades_request with one trade_change per row, the batch delete as a delete_many_trades_request with one trade_removal per row, and the four families with no model restored to the consolidated response check the codemod over-rewrote. The four restores are the only hand edits, and the compiler named each one. ores.ore.service.lib then builds clean.

D8 is applied here. The handler records the trade's external identifier at the save site, and the rollback addresses the trade by that key. Because the field carries an identifier and not a uuid, the ores.ore model renames it saved_trade_external_ids, and its generated C++ and TypeScript and its hand-drawn diagram follow the model. No trading model changes.

4.14. The eighth trial: the hand-written shell trades migrate

The shell trading surface is two trees. ores.shell/trading holds 22 generated command units; ores.shell/application/src/app/commands/trading holds 17 hand-written ones, which are the nine rates families, the four families with no model, bond instrument, trade and two aggregators. The second tree is what consumes the consolidated protocol and is what this trial migrates.

The codemod grew nine rules, each found by building: evidence/pr2_shell_migration_trial8.txt. Six are mechanical: the history verb renames to versions; a response type renames with its request wherever it appears, including a do_auth_request< template argument; the subject literal becomes std::string(req.nats_subject); the include list gains the entity's generated protocol and drops the consolidated header when no family without a model is named; the row key moves under req.key or req.removal.key, with a uuid key parsed by string_generator; and the generated list response names its rows, versions and total itself.

Three are structural. A request declared through a dependent type, do_auth_request<typename request_type::response_type>, is canonical by construction, so its response takes the result member. Entity tracking now follows the declaration in force, so a consolidated declaration clears the variable and the four families with no model no longer inherit the previous branch's entity; that removes the four hand restores the seventh trial needed, and the codemod is now a no-op on a second run. And the struct reader joins a declaration that wraps onto the next line.

Two decisions belong to a person:

  • The shell add-trade command builds one trade inline. The inline object now has a name, and the codemod expands it into the single-row put_trade_request. The batch form is not used, because the command adds one row. This is the second place the container shape needs a choice, and it is the same choice the ORE rollback made in the opposite direction.
  • The registration function mixes two signatures. The generated units take (root_menu, session) and the hand-written ones take (root_menu, session, pagination), so trading_commands.cpp drops the pagination argument from the 22 generated calls and keeps it on the other 15.

One loss is recorded rather than hidden. Two files, commodity_instrument_commands.cpp and scripted_instrument_commands.cpp, carried one added line each before this trial. A git checkout HEAD -- run to clear a bad codemod run overwrote both, and the line is in no commit, so its content is lost. Both files compile and the migration leaves them unchanged; the lesson is that a worktree with uncommitted work is restored from the index, never from HEAD.

4.15. Regenerate

Run the drift check without --dry-run. It writes the tree. Then read the diff in the order of its causes, not in file order: the banner first, then the eventing rename, then the 114 SQL scripts, then the creates.

projects/ores.codegen/venv/bin/python projects/ores.codegen/scripts/check_component_drift.py --component trading-cpp

If the regeneration reveals a generator defect, fix it at its source with a regression test in the codegen suite, then regenerate. Do not edit generated output.

4.16. Migrate the consumers

The canonical list verb renames get_trades_request to list_trades_request and h->list to h->list_trades, and the eventing headers rename. Consumers outside the component: 38 files in ores.shell, 17 in ores.ore, 1 in ores.reporting. Migrate all of them in this PR. Nothing is kept for compatibility.

4.17. Fix the CMake source lists

projects/ores.codegen/venv/bin/python projects/ores.codegen/scripts/regenerate_cmake_component_files.py --component ores.trading.api
projects/ores.codegen/venv/bin/python projects/ores.codegen/scripts/regenerate_cmake_component_files.py --component ores.trading.core
projects/ores.codegen/venv/bin/python projects/ores.codegen/scripts/regenerate_cmake_component_files.py --component ores.trading.service

4.18. Build and test

Build the whole tree, not only the component, because the canonical protocol change reaches ores.shell, ores.ore and ores.reporting. Then run the four suites.

cmake --build build/output/linux-clang-debug-make -j 3
ctest --test-dir build/output/linux-clang-debug-make -R "ores.trading|ores.shell.trading|ores.ore" --output-on-failure

4.19. Prove idempotence

Run the dry run twice more and require both runs to report nothing. A second run that still changes something is a generator defect.

4.20. Record the items

Update the story's checklist rows for G01, G02, G03, G04, G06, P01, P02, P03, P05, W01, V01 and V02. Record which gates were run before the component joins the registry and which are still vacuous.

5. Notes

  • The trap this PR exists for is the double registration described in step 2. It is decision D1 on the story.
  • The eventing work here closes ores.trading's share of Finish the eventing payload migration, or read the legacy shape. Read that capture before touching the trigger template.
  • The in-place drift check diffs the whole repository. Any untracked file makes it fail for reasons that are not this component's. The eleven untracked shell library directories from the scheduler pass are the known case. Keep the tree still and never run a writing gate while a build reads the tree.
  • The PR is large and mostly mechanical. The review evidence is the cause classification, the build, and the four suites, not a line by line read.
  • Two hand-written generator trees exist in the component, api/.../generator/ with 25 files and api/.../generators/ with 38. Do not collapse them here; record them for PR 6.
  • 24 hand-written domain types have no model. Do not model them here; PR 3 owns that.

6. Test Scenarios

Scenario State Notes
     

7. PRs

PR Title
#2204 Sync the ores.trading tree to its models

8. Review

Comment summary File Decision Notes
       

9. Rebase before merge

Main moved 197 commits while this PR was open. The rebase held one conflict, in doc/recipes/shell/shell.org, which is derived: main's version was taken and the inventory regenerated on top, so both sides' rows are present. The rebase also brought a main-side change the trading tree had not seen: two models name ores.storage/net/storage_transfer.hpp, a path that does not exist, and the regeneration followed it into trade_handler.hpp and broke the build. The trade model now names the header that exists, ores.storage.core/net/storage_transfer.hpp, and the marketdata half is recorded on the story for that component. Neither trading nor marketdata is under the drift gate, which is why the wrong path sat unnoticed.

10. Verification, round 1

An independent verifier read the diff against the six claims the task makes. It confirmed D1, the trade eventing change, the header deletion and the trade key record, found the codemod a genuine no-op, and raised four findings. All four are answered here.

  1. Blocker: four dead subjects in the bond shell unit. bond_instrument_commands.cpp handed save_row the literals trading.v1.bond_issues.save and three siblings. The migration renamed the types but not a literal that a helper forwards, and no handler answers ...save, so bond add would time out. Fixed: save_row derives the subject from the request, std::string(request.nats_subject), so the row cannot be sent to a subject no handler answers. The codemod now reports a subject literal that survives a migration, because a literal a helper forwards is beyond a textual pass; the four reports that remain are the four families with no model, whose consolidated subjects are still correct.
  2. Should-fix: the W01 history provider count was wrong. The first read called 31 of 31 composed; it counted declarations, not call sites, and 21 were never composed. Caught by the verification and fixed: the 21 are composed in registrar.cpp, and the read is 31 of 31 by call site. The correction is written into evidence/pr2_w01_composition_read.txt.
  3. Note: the trading diagram still declared the deleted type. ores.trading.api.puml showed trade_changed_event. It now shows the canonical trade_event with its own members.
  4. Note: the decision said no model changed, and one did. D8 now states what changed: the trading protocol does not change, and the trade model changes only in the key's fixture generator, which is defect 17's PR 2 half.

Two claims the verifier could not re-derive are recorded as derived by script here: the three nullable declared keys (a script over every modeling directory found exactly business_unit.unit_code, account_contact_information.email and trade.external_id), and the database trigger counts, read with compass sql before and after the environment's refresh.

11. Result

PR 2 is done. The evidence is evidence/pr2_acceptance_checks.txt.

  • The tree is regenerated: 1,463 files under ores.trading, 114 SQL scripts, the eventing headers, the shell command units and their recipes, and the TypeScript projection.
  • D1 is applied: the consolidated rates protocol is deleted and the nine families are served by their generated handlers and registrars.
  • The consumers are migrated by projects/ores.codegen/scripts/migrate_consumer_to_generated_protocol.py, whose rules and trials are recorded in evidence/pr2_migration_trial7.txt and pr2_shell_migration_trial8.txt.
  • W01 is closed by reading the composition points, not by trusting the drift gate: twenty-five event mappings were declared and unwired, and the trade family's legacy block was the reason the superseded trade_changed_event header stayed alive. evidence/pr2_w01_composition_read.txt.
  • A model defect surfaced here and is recorded as defect 17 on the story: the trade's declared key was nullable and its fixture left it empty. evidence/pr2_trade_key_fixture_defect.txt.
  • One loss is recorded rather than hidden: a git checkout HEAD -- run overwrote two one-line edits in the files with no model. They compile and the migration leaves them unchanged, and the loss is written down in pr2_shell_migration_trial8.txt.

Gates: the whole tree builds; ores.trading.api.tests, ores.trading.core.tests, ores.trading.service.tests, ores.shell.trading.tests, ores.shell.application.tests, ores.ore.api.tests, ores.ore.core.tests and ores.ore.service.tests pass; the drift check reports no drift on two dry runs; the three CMake source lists are up to date.

Emacs 29.3 (Org mode 9.6.15)