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
* Messagessection declares a request/response pair, with:subject:and:response:drawers on the request and typed*** <field>children on both (seeget_party_composite_as_of_requestand its response); - the
** Custom NATS handler methodssection carries#+begin_src cpp :name custom_handler_methods :implements B9E2F4A1-3C7D-4E8B-A5F0-2D6C1B8E3A7Fwith 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.