Task: Resolve a tenor over the wire, with the refusal it carries

Table of Contents

This page documents a task in the Close the tenor journey and the remaining refdata gaps story. It captures the goal, current status, acceptance, and any notes or results.

1. Goal

Journey 9's resolution column and resolved-dates step show a date the server resolved and confirmed, and a resolution the server refuses arrives as something a person can read.

2. Status

Field Value
State DONE
Parent story Close the tenor journey and the remaining refdata gaps
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-10-16

3. Acceptance

  • A refdata operation resolves a tenor under a convention and replies with the anchor date, the date before the roll, the adjusted date, the roll rule and the calendar.
  • A resolution the server refuses carries the step, the dates it counted and the row at fault, rather than a thrown exception.
  • Journey 9's remaining two missing coverage rows read exists and name the subject that serves them.

4. Plan

The reconnaissance below said to call resolve_end_date, the hand-written resolver in refdata. It does not: the pure batch engine that landed after it is the one implementation of the arithmetic, and this operation is its first consumer. resolve_end_date applies no business-day convention at all, so wiring the wire to it would have shipped a date the convention column does not agree with.

xrefdata.v1.ops.resolve_tenor, not refdata.v1.tenors.resolve. The subject-conformance gate settles it: the generated per-entity namespace has a closed verb set of eight and resolve is not one of them, while ops. is exempt and is where the other hand-written refdata operations sit.

The pair is declared in the tenor model, so the C++ protocol header and its TypeScript twin render from one declaration, and the handler is pasted into the generated tenor_handler and subscribed by the generated tenor_registrar. Nothing is hand-added to a generated file.

tenor_resolution_service owns the mapping and nothing else: it reads the tenor, the convention and the resolution row, materialises the named calendar's business days into the engine's holiday set, and reports what the engine says. It refuses rather than throws.

Two refusals are guards the engine cannot make for itself: schedule_under_anchor_offset (a row naming a schedule under a convention whose algorithm never walks one) and schedule_not_walkable (a closed form this engine does not carry). Both are cases where resolving one reading and silently dropping the other would answer a question nobody asked.

The engine reads a schedule only under SCHEDULE_STEP; a row's membership alone resolves on the calendar axis. That is the documented fallback, and it is what the first draft of the tests got wrong.

5. 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
Cases in ores.refdata.core.tests 443 ./compass.sh test run -- -R ores.refdata.core.tests 855dadabcf
Cases this task adds there 10 ./publish/bin/ores.refdata.core.tests "[service][tenor_resolution]" in the preset's build tree 855dadabcf
Assertions in those ten 46 as above 855dadabcf
Cases in ores.analytics.quant.tests 435 ./compass.sh test run -- -R ores.analytics.quant.tests 855dadabcf
Files changed against origin/main 18 git diff --shortstat origin/main...HEAD 855dadabcf
Lines changed against origin/main +1167 / -24 as above 855dadabcf
Whole-tree build 1766 targets, clean ./compass.sh build 855dadabcf
Local gate checks passing 31 of 34 python3 build/scripts/local_checks.py --exclude-heavy 855dadabcf
Live subscription round-trip 748 µs, replied nats --server localhost:21605 --tlsca build/keys/nats/ca.crt --tlscert build/keys/nats/ores.shell.crt --tlskey build/keys/nats/ores.shell.key req ores.dev.clever_dijkstra.refdata.v1.ops.resolve_tenor '' --timeout 5s 855dadabcf

The three gate failures are all in reporting and none is this branch's: er-diagram is the stale ores_schema.reporting.puml, and handler-permissions and codegen-tests are the unseeded reporting::report_errors:* and reporting::report_error_scopes:*. This branch touches no file under =projects/ores.reporting and no SQL at all.

6. Notes

What the wire evidence covers, and what it does not. The subject is live: a request against it on the running environment answers in 748 microseconds, which is the handler refusing a session-less caller rather than a "no responders" from the server. So the subscription, the registration and the handler's dispatch are proven on the running fleet. The resolution itself is proven against real database rows through tenor_resolution_service, which is the whole of the logic between the handler and the engine.

An authenticated call end to end is not made here, because nothing calls the operation yet: the shell has no generic request verb and the BFF has no route. The client that will make it is the trader resolve screen, 335C4014, which is the next task. The session, permission and codec path it will use is the same one 285 other subjects use and is not new here.

Split out of Resolve a tenor over the wire, and make the tenor machinery writable. That task built the two things this operation depends on – the roll rule on tenor_convention and the writes on tenor_convention_resolutions – and filed this one, because a new hand-written handler with its own protocol pair, registration and permission is a unit of its own and it could not be written before the roll rule existed to report.

Reconnaissance at cff0559411 (trunk).

What to call. resolve_end_date is the whole of the logic and it already exists: projects/ores.refdata/api/include/ores.refdata.api/domain/tenor_resolution.hpp, signature resolve_end_date(const tenor&, const tenor_convention&, const std::optional<tenor_convention_resolution>&, std::chrono::year_month_day horizon, std::chrono::year_month_day spot, const std::optional<std::vector<std::chrono::year_month_day>>& schedule_dates = std::nullopt). It is pure: everything it reads is passed in. The handler's work is therefore loading the three rows and the two dates, calling it, and reporting what it did. The doc comment above it names exactly which conditions throw std::invalid_argument and which throw std::logic_error, so the refusal payload's cases are already enumerated.

The pattern to copy. A custom operation is declared in the entity's model and implemented in a named src block, not hand-edited into a generated file. In projects/ores.refdata/modeling/ores.refdata.party.org:

  • the * Messages section declares a request/response pair, with :subject: and :response: drawers on the request and typed *** <field> children on both (see get_party_composite_as_of_request and its response);
  • the ** Custom NATS handler methods section carries #+begin_src cpp :name custom_handler_methods :implements B9E2F4A1-3C7D-4E8B-A5F0-2D6C1B8E3A7F with the handler body.

Both the C++ protocol header and its TypeScript twin render from that one declaration, so the client sees the new pair without a second edit. The registrar subscribes whatever the model declares.

The subject. The journey names none, and the composite reads sit under refdata.v1.ops.*. A refdata.v1.ops.resolve_tenor keeps the generated list and get subjects for the catalog untouched and puts the operation where the other hand-written refdata operations live; refdata.v1.tenors.resolve would read better but would sit inside the generated per-entity namespace, so check which one the subject-conformance gate expects before choosing.

A permission is needed. The operation is a read but it is not one of the generated reads, and check_handler_permissions.py classifies by the request a method decodes. Decide whether it takes refdata::tenors:read (it reads the catalog and resolves, and writes nothing) or a permission of its own, and seed it if it needs one.

The refusal belongs with the operation, not before it. The journey's second missing row is the refusal payload, and there is nothing to carry a refusal until the operation exists. Build them together, and prefer a failure reply on the resolve response over mapping a typed error in the handler, because the shared result already has the shape – outcome, code, message and the fields vector – and the assets and IAM services already fill fields.

What landed already, so do not rebuild it. tenor_convention carries business_day_convention_type, validated by ores_refdata_validate_business_day_convention_type_fn and defaulting to ModifiedFollowing, which is the roll rule the reply reports. tenor_convention_resolutions takes put, put_many, delete and delete_many, with refdata::tenor_convention_resolutions:write and :delete seeded. The tenor write already carries its change reason: put_tenor_request takes an intent whose reason_code and commentary the service copies onto the row, so the journey row that said it did not was stale and has been corrected.

7. 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
     

8. PRs

PR Title
#3413 [refdata] Resolve a tenor over the wire, with the refusal it carries

9. Review

Comment summary File Decision Notes
       

10. Result

refdata.v1.ops.resolve_tenor resolves a tenor under a convention and replies with the anchor date, the date before the roll, the adjusted date, the roll rule and the calendar it rolled on. A resolution the server declines is a non-ok result on the same reply, carrying the step that refused, the row at fault and the dates the resolution had reached, rather than an exception reaching the screen as a 500. Journey 9's coverage table has no missing row left: the three that were are this one operation, and the table now names its subject.

It is built on the pure engine rather than on the hand-written resolver the reconnaissance pointed at, because that resolver applies no business-day convention at all. The operation is the engine's first consumer.

Merged to main. The three acceptance bullets are met, with one gap stated rather than glossed: no authenticated call was made end to end, because nothing calls the operation yet. The evidence at the wire is the live subscription answering a session-less request in 748 microseconds, and the evidence for the resolution itself is ten cases against real database rows through the service, which is the whole of the logic between the handler and the engine. The client that closes the last gap is the trader resolve screen, 335C4014.

Emacs 29.3 (Org mode 9.6.15)