Task: Build Book a trade

Table of Contents

This page documents a task in the Trade Blotter — the remaining work story. It captures the goal, current status, acceptance, and any notes or results.

1. Goal

Build Book a trade from doc/prototypes/trades/book-a-trade. Settle two open items of the prototype audit: the enforcement of a change reason that requires commentary, and a screen to add a Hedge or UserDefined link.

2. Status

Field Value
State DONE
Parent story Trade Blotter — the remaining work
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-10-21

3. Acceptance

  • A trade books into an open book and refuses a Frozen or Closed book.
  • A trade can be a leg of a structure, in a chosen role.
  • Each open item has a recorded decision.

4. Plan

(Implementation strategy. Written when work starts; key decisions are distilled into the parent story's * Decisions at close, but the plan itself stays — it is the historical record of what we did.)

5. Plan

Analysis before the screen, so the two open items and the acceptance criteria are decided rather than discovered.

5.1. The write exists, and it is one operation

trading.v1.ops.book_trade takes the trade's anchor, its booking, the activity_type_code that starts its state (new_booking for a live trade, draft_capture for a draft), its identifiers, and one batch of instrument rows. The service already refuses four things by name: an instrument array that does not belong to the trade type, a child row with no header, a bond part no writer handles, and a trade id that is already booked. The screen therefore states a request and reads the refusal; it does not decide whether a booking is legal.

5.2. The open-book rule does not exist yet, and it is not a screen's job

No code in the trading component names a book status: grep -rn book_status projects/ores.trading finds nothing outside tests. So "a trade books into an open book and refuses a Frozen or Closed book" cannot be met by the screen alone. Two decisions to take, and the task should take one rather than both:

  • Trading reads the book from refdata by id at booking time and refuses a book that is not open. That makes trading depend on a refdata read inside its transaction, and the status is current at the moment of the booking.
  • Booking copies the status onto the booking row. That is cheaper and keeps trading self-contained, but a book frozen after the copy is not honoured, which is the case the rule exists for.

The first is the honest one. I would rather the service ask than keep a stale answer.

5.3. A structure leg

The anchor, the activity and the instruments are the same. What differs is the member row that names the structure, the leg's role, and the ordering. structure_members:write is already held by Trading, so the screen needs no new grant.

5.4. The two open items of the audit

  • A change reason that requires commentary. The rule is data: a change reason states whether it requires commentary. The service is the only place that can enforce it for every caller, and the screen should surface the same flag rather than invent it. Decision: enforce in the service against the reason's own flag, and let the screen mark the field as required when the chosen reason says so.
  • A screen to add a Hedge or UserDefined link. A small form rather than a page: the trade, the other trade, and the link type. trade_links:write is held by Trading. It belongs on the trade page, beside Links, not in the booking screen.

5.5. Where a cross-service rule can live, now that I have looked

trade_operations_service is a database service: it takes a context and nothing else. It holds no NATS client, and the handler that owns one is written to decide nothing — "the adapter decides nothing: it proves the request, checks the permission, decodes it, calls the service and replies with the service's answer". So a refdata read inside the booking cannot go into the service as it stands, and putting it in the adapter would break the rule the handler's own documentation states.

Three places it could go, with what each costs:

  • The service gains a port — an interface it calls to ask a book's status, implemented by the service process with the NATS client. The service stays testable and the adapter decides nothing. This is the largest change and the only one that keeps the existing layering intact.
  • The handler checks before it calls — small, and wrong by the file's own doctrine; it also means any other caller of the operation skips the rule.
  • The caller states a precondition — the request already carries precondition, which the service enforces, so the BFF could read the book and state "this book is open". That is a client-side rule dressed as a precondition: a caller that lies is believed, and the acceptance criterion says the service refuses, not that the screen does.

I would take the first. It is not a screen change and should not be bundled with one.

5.6. What the port's implementation needs

projects/ores.refdata/client holds a cache and no typed read for a book, so the port's implementation cannot call a ready-made client method. It has to make the request itself, in the shape trading_trade_population_writer already uses when it calls other services over NATS: encode the list request, read the reply, and treat a refusal as "unknown" rather than as "open".

That makes the change a half-window one rather than a small edit: an interface, its implementation, the wiring in two places, and a test that a frozen book is refused. It is worth starting a fresh window with, not finishing at the end of one.

5.7. The real obstacle: the read must travel as the caller

Three attempts at the bus-backed port each fell one layer lower, and the last one named the actual problem.

  • The component did not depend on the refdata API. Fixed: ores.refdata.api.lib is declared in the component's model and the generated CMake carries it.
  • The request needed rfl's uuid registration. Fixed the same way other code does it: ores.utility/rfl/reflectors.hpp, which the trade population writer includes for exactly this.
  • Then the obstacle: ores::nats::service::client offers request_sync with hand-made headers, and the booking service holds no caller token. A read made by the service would be anonymous, and refdata would refuse it — correctly, because a book's visibility is the caller's, not the service's.

So the port's signature is wrong, not its body. It needs the caller's session token, which means either the port takes one and the booking service's signature carries it, or the read happens in the adapter that already holds the request context. That is a design decision I am not going to take in the last minutes of a window, having already reverted three times.

The dependency and the rfl registration stay: they are correct and were missing, and the next attempt starts from them rather than rediscovering them.

5.8. The rule, proved end to end

Two tests now hold it, and between them they cover the whole path.

The service's own case, a_booking_into_a_book_that_is_not_open_is_refused, states a port that says not_open and gets failed with book_not_open.

the port asks as the caller and maps the book's status covers what the service case cannot: the port over a real bus. It answers on a subject of its own and holds the port to four things — the question names the book asked about, the header is X-Delegated-Authorization: Bearer <the caller's token>, Active maps to open, and Frozen and Closed map to not open. That is the encoding, the delegation and the mapping in one place.

The whole trading suite is green on a freshly created database: 221 cases, 1416 assertions.

6. What the earlier failures taught, recorded so they are not paid for twice

  • A cross-component dependency is declared in the component's component_overview.org, not in the generated CMake.
  • A request carrying a uuid needs ores.utility/rfl/reflectors.hpp.
  • ores::nats::service::client is the raw connection: it has request_sync, and the helper that reads a caller's token lives in ores::nats::service.
  • A class a test binary must link needs the component's export macro, because visibility is hidden by default.

6.1. What the form needs to read

The books the party may book into, the counterparties it may trade with, the trade types with their product type (which decides the instrument table), the identifier schemes, and the instrument's terms for the chosen type. All of these are reads that exist; the books read needs the open rule above to be useful.

7. Measurements

Every number this task claims about a tree, with the command that produced it and the commit it was measured at. A number without both is not a measurement, because a clean-up record describes a tree and trunk moves under a branch: a consumer count, a gate's component count and a suite's size all change with no code changing, and the reader of a bare number cannot tell which tree it came from. Where the raw output matters, put it in an evidence/ file beside the task and name that file in the Command column.

What Value Command Commit
       

8. Notes

9. Test Scenarios

Manual QA scenarios (scaffolded via compass add test_scenario) that verify this task. Link new ones here as they're created; the scenario doc itself links back via its "Verifies task" field.

Scenario State Notes
Verify Book a trade PENDING The form, its two visible rules, and the page a booking opens.
Verify the structure screens ABANDONED Walked once with three findings, all answered, and the screens approved on 2026-10-28. Abandoned on 2026-10-30: the structures list it walks is the blotter's rows now, and /trading/structures redirects there. The structure page half is covered by specs/structures.spec.ts and by the blotter's scenario.
Verify the trade blotter PENDING The one Trading screen: the portfolio tree and its counts, the top rows, narrowing to a book and a portfolio, the flat list, the counterparty grouping, the pages a row opens, and grouping from the screen's button.

10. PRs

PR Title
#3951 [web] Give a deal a page, and let a trade be its leg
#3928 [trading] Name a structure, and serve the family that creates one
#3936 [iam] Unblock main: seed the structure identifier permissions
#3907 [trading] Model the identifiers a structure carries
#3896 [iam] Copy the trading code lists into each tenant
#3878 [web] Name the trade at the other end of a link
#3862 [agile] Let the booking scenario prepare itself
#3843 [web] Book a trade
#3840 [agile] Capture the status command's bootstrap read
#3838 [web] Give the browser cases one way to sign in
#3837 [trading] Prove the booking rule over a real bus
#3832 [trading] Ask for the book's status as the caller
#3822 [agile] Map the booking write before the screen

11. Review

# Comment summary File Decision Notes
1 The unlink route deleted by trade alone, so a key of one deal took a leg out of another bff/src/structures.ts Accepted The member is read and its structure compared before the write; a refusal says which fault it is.
2 Grouping wrote the structure before checking what it held bff/src/structures.ts Accepted Kind, duplicates and existing membership are checked before the write.

12. Result

13. The rule, landed

The booking service now asks a book_status_port whether the book is open and refuses a book that is not, with book_not_open. The port is required by the constructor rather than defaulted, so a caller cannot forget it and apply the rule to some bookings and not others. The service stays a database service: it holds an interface, not a bus.

The service registrar passes a port that knows nothing until the service process is given a bus-backed one, and the rule logs and carries on when the answer is unknown. That is deliberate: refusing every booking while refdata is busy would be a worse fault than the one the rule prevents. Wiring the bus-backed port is the next step, and it is the only reason the rule is inert today.

Proved by a_booking_into_a_book_that_is_not_open_is_refused: a fake port that says not_open produces failed with book_not_open, and the 31 existing cases pass through a port that says open.

14. The structure screens, and the booking that joins one

A structure is a tree the desk groups, and the arc built the screens that show it. The list draws the tenant's trees, indented, with what each row holds. A deal's page states its identity, how it confirms and prices, its legs, and the tree it sits in, drawn beside the page as the prototype draws it: a twisty folds a structure, and a trade row selects the leg being read, with the choice in the address as ?leg.

The History tab is the shared timeline, not the legacy compare panel: the stream is the deal's own versions and those of every leg it holds now, merged, each leg's entry named by its leg. A leg taken out of the deal is not in the stream, and the panel says so.

Booking a leg now joins it. The button on a deal names the deal and the role, the form states the deal it will join and offers the deal's counterparty rather than another, and the trade is a leg before the form closes; the deal's page answers with the new leg in place.

Three reads that came back empty, each hidden by a fallback, were found and fixed. A filter may name only the members its struct declares: the type catalogue's filter named code beside code_one_of and was refused. The response names its array types, not trade_types. And the party of a deal's legs is named from the login session, because the read is party-scoped and the trading roles hold no refdata::parties:read.

The trading roles now read the type catalogue the screens read, verified on a recreated database. The trade's own history provider was generated but never registered; it is registered now, and the trade page draws the shared timeline over the trade, its booking and the structure it is a leg of.

Proved by specs/structures.spec.ts and specs/trade-page.spec.ts over the provisioned population, by the BFF's 1101 cases and the web's 2255.

Emacs 29.3 (Org mode 9.6.15)